12 Commits

Author SHA1 Message Date
d716b5628a WIP: tui 2024-05-18 23:29:11 +02:00
76aaaa0a45 poetry: add textual, lock 2024-05-18 23:28:55 +02:00
e00ae9e344 bruh 2024-05-18 19:02:18 +02:00
0536a56ca6 Merge pull request #10 from Programvareverkstedet/you-happy-now-daniel
Rework grzegorzctl interface
2024-05-18 00:28:27 +02:00
a9e8330898 Rework grzegorzctl interface 2024-05-18 00:10:26 +02:00
3059898e38 Improve error messages produced by http decorators 2024-05-17 23:40:42 +02:00
738a4f3dd8 webui: view slice if len(playlist) > 100 2024-05-11 01:44:11 +02:00
7e8baa0a48 lkjlkjdsalkj 2024-05-10 20:04:30 +02:00
f3cbb43f91 poetry lock
dependabot bad
2024-05-10 18:52:27 +02:00
c38f2f22a6 lock 2024-03-31 04:48:21 +02:00
b23b02b5e8 grzegorzctl 2024-03-31 04:48:14 +02:00
99f41e54c4 flake shell 2024-03-31 04:45:22 +02:00
13 changed files with 1541 additions and 495 deletions

2
.envrc
View File

@@ -3,7 +3,7 @@
# It enters you into the poetry venv, removing the need for `poetry run`.
if command -v nix >/dev/null; then
use nix -p poetry
use flake || use nix -p poetry
fi
# Instead of using the flake, we use poetry to manage a development venv

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ __pycache__/
*.pyc
result
result-*
.direnv/

View File

@@ -5,18 +5,26 @@ A set of simple API endpoints and ready-to-go clients to interface with the [Grz
#### Working clients:
* A webUI client made with REMI
* CLI client
#### Planned future clients:
* CLI client
* WebExtensions browser extension
## How to run this
pip install --user git+https://github.com/Programvareverkstedet/grzegorz_clients.git#master
### cli
grzegorzctl
### webui
As the user intended to run the server:
pip install --user git+https://github.com/Programvareverkstedet/grzegorz_clients.git#master
grzegorz-webui --host 0.0.0.0 --port 80
grzegorz-webui --host-name 0.0.0.0 --port 80
It's rather insecure and could use a reverse proxy and some whitelisting. ;)

4
dev.sh
View File

@@ -1,5 +1,5 @@
#!/bin/bash
if ! which entr > /dev/null; then
#!/usr/bin/env bash
if ! command -v entr > /dev/null; then
echo "entr is not installed, aborting..."
exit 1
fi

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1692174805,
"narHash": "sha256-xmNPFDi/AUMIxwgOH/IVom55Dks34u1g7sFKKebxUm0=",
"lastModified": 1711703276,
"narHash": "sha256-iMUFArF0WCatKK6RzfUJknjem0H9m4KgorO/p3Dopkk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "caac0eb6bdcad0b32cb2522e03e4002c8975c62e",
"rev": "d8fe5e6c92d0d190646fb9f1056741a229980089",
"type": "github"
},
"original": {

View File

@@ -30,6 +30,8 @@
#"riscv64-linux"
];
in {
inherit inputs;
packages = forAllSystems ({ pkgs, flakes, ...}: {
remi = with pkgs.python3.pkgs; buildPythonPackage rec {
pname = "remi";
@@ -53,7 +55,7 @@
format = "pyproject";
src = ./.;
nativeBuildInputs = [ poetry-core ];
propagatedBuildInputs = [ setuptools flakes.self.pkgs.remi requests typer urllib3 ];
propagatedBuildInputs = [ setuptools flakes.self.pkgs.remi requests typer rich urllib3 ];
};
default = flakes.self.pkgs.grzegorz-clients;
});
@@ -61,7 +63,9 @@
apps = forAllSystems ({ system, ...}: rec {
grzegorz-webui.type = "app";
grzegorz-webui.program = "${self.packages.${system}.grzegorz-clients}/bin/grzegorz-webui";
default = grzegorz-webui;
grzegorzctl.type = "app";
grzegorzctl.program = "${self.packages.${system}.grzegorz-clients}/bin/grzegorzctl";
default = grzegorzctl;
});
nixosModules.grzegorz-webui = { config, pkgs, ... }: let
@@ -126,5 +130,15 @@
};
};
};
devShells = forAllSystems ({ pkgs, ... }: rec {
default = pkgs.mkShellNoCC {
packages = [
pkgs.poetry
pkgs.python3
pkgs.entr
];
};
});
};
}

View File

@@ -11,40 +11,88 @@ def set_endpoint(base_url:str):
# Exceptions:
class APIError(Exception): pass
def parse_message(
method: str,
url: str,
status: str,
function_name: str,
json_text: str,
is_get: bool,
) -> dict[str, any]:
prefix = f"[{function_name}] {method} /{url} -> {status}:"
try:
data = json.loads(json_text)
except json.JSONDecodeError:
raise APIError(f"{prefix} Expected json response, got:\n{json_text}")
if type(data) is not dict:
raise APIError(f"{prefix} Expected json response to be a dict, got:\n{json_text}")
if "error" not in data:
raise APIError(f"{prefix} Missing json data 'error', got:\n{json_text}")
if data["error"] != False:
raise APIError(f"{prefix} Got error {str(data['error'])}, got:\n{json_text}")
if not is_get and "success" not in data:
raise APIError(f"{prefix} Missing json data 'success', got:\n{json_text}")
return data
# decorator:
# (TODO): Add logging
def request_delete(func):
@wraps(func)
def new_func(*args, **kwargs):
url, data = func(*args, **kwargs)
if type(data) is dict: data = json.dumps(data)
response = requests.delete(f"{BASE_URL}/{url}", data=data)
data = json.loads(response.text)
if "error" not in data or data["error"] != False:
print(data)
raise APIError(data["error"])
response = requests.delete(f"{BASE_URL}/{url}", json=data)
response.raise_for_status() # raises HTTPError, if any
data = parse_message(
"DELETE",
url,
response.status_code,
func.__name__,
response.text,
is_get = False,
)
return data["success"]
return new_func
def request_post(func):
@wraps(func)
def new_func(*args, **kwargs):
url, data = func(*args, **kwargs)
if type(data) is dict: data = json.dumps(data)
response = requests.post(f"{BASE_URL}/{url}", data=data)
data = json.loads(response.text)
if "error" not in data or data["error"] != False:
print(data)
raise APIError(data["error"])
response = requests.post(f"{BASE_URL}/{url}", json=data)
response.raise_for_status() # raises HTTPError, if any
data = parse_message(
"POST",
url,
response.status_code,
func.__name__,
response.text,
is_get = False,
)
return data["success"]
return new_func
def request_get(func):
@wraps(func)
def new_func(*args, **kwargs):
url = func(*args, **kwargs)
response = requests.get(f"{BASE_URL}/{url}")
data = json.loads(response.text)
if "error" not in data or data["error"] != False:
raise APIError(data["errortext"])
response.raise_for_status() # raises HTTPError, if any
data = parse_message(
"GET",
url,
response.status_code,
func.__name__,
response.text,
is_get = True,
)
if "value" not in data:
raise APIError(f"[{func.__name__}] Missing json data 'value', got:\n{json.dumps(data)}")
return data["value"]
return new_func
@@ -69,7 +117,7 @@ def get_volume():
return "volume"
@request_post
def set_volume(volume: int): # between 0 and 100 (you may also exceed 100)
def set_volume(volume: float): # between 0 and 100 (you may also exceed 100)
args = urlencode(locals())
return f"volume?{args}", None
@@ -116,7 +164,6 @@ def get_playlist_looping():
def playlist_set_looping(looping: bool):
return f"playlist/loop?loop={str(bool(looping)).lower()}", None
@request_get
def get_playback_pos():
return "time"

165
grzegorz_clients/cli.py Normal file
View File

@@ -0,0 +1,165 @@
from . import api
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import json
import os
import rich
import shutil
import subprocess
import sys
import typer
# export GRZEGORZ_DEFAULT_API_BASE="https://georg.pvv.ntnu.no/api"
# export GRZEGORZ_DEFAULT_API_BASE="https://brzeczyszczykiewicz.pvv.ntnu.no/api"
# export GRZEGORZ_DEFAULT_API_BASE="https://georg.pvv.ntnu.no/api;https://brzeczyszczykiewicz.pvv.ntnu.no/api"
DEFAULT_API_BASE = os.environ.get("GRZEGORZ_DEFAULT_API_BASE", "https://brzeczyszczykiewicz.pvv.ntnu.no/api")
if ";" in DEFAULT_API_BASE:
if shutil.which("gum"):
DEFAULT_API_BASE = subprocess.run([
"gum", "choose",
*DEFAULT_API_BASE.split(";"),
"--header=Please select an endpoint...",
], check=True, text=True, stdout=subprocess.PIPE).stdout.strip()
else:
DEFAULT_API_BASE = DEFAULT_API_BASE.split(";", 1)[0]
def print_json(obj):
if not isinstance(obj, str):
obj = json.dumps(obj)
rich.print_json(obj)
cli = typer.Typer(no_args_is_help=True)
def _add(
urls : list[str],
put_pre : bool = False,
put_post : bool = False,
play : bool = False,
api_base : str = DEFAULT_API_BASE,
):
api.set_endpoint(api_base)
if put_pre or put_post:
pre = api.get_playlist()
for url in urls:
resp = api.load_path(url)
rich.print(f"{url} : {resp!r}", file=sys.stderr)
if put_pre or put_post:
assert put_pre != put_post
post = api.get_playlist()
current_index, = [i.get("index", -1) for i in post if i.get("current", False)][:1] or [0]
old_indices = set(i.get("index", -1) for i in pre)
new_indices = set(i.get("index", -1) for i in post) - old_indices
assert all(i > current_index for i in new_indices)
target = current_index if put_pre else current_index + 1
if target not in new_indices:
for idx in sorted(new_indices):
api.playlist_move(idx, target)
target += 1
if play:
assert put_pre or put_post
api.playlist_goto(current_index if put_pre else current_index + 1)
api.set_playing(True)
@cli.command(help="Add one ore more items to the playlist")
def play(
urls: list[str],
pre: bool = False,
api_base: str = DEFAULT_API_BASE,
):
_add(urls, put_post=not pre, put_pre=pre, play=True, api_base=api_base)
@cli.command(help="Add one ore more items to the playlist")
def next(
urls: list[str],
api_base: str = DEFAULT_API_BASE,
):
_add(urls, put_post=True, api_base=api_base)
@cli.command(help="Add one ore more items to the playlist")
def queue(
urls: list[str],
play: bool = True,
api_base: str = DEFAULT_API_BASE,
):
_add(urls, api_base=api_base)
if play:
api.set_playing(True)
@cli.command(name="list", help="List the current playlist")
def list_(
api_base: str = DEFAULT_API_BASE,
):
api.set_endpoint(api_base)
print_json(api.get_playlist())
@cli.command(help="Set Playing to True")
def resume( api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
# TODO: add logic to seek to start of song if at end of song AND at end of playlist?
rich.print(api.set_playing(True), file=sys.stderr)
@cli.command(help="Unset Playing")
def pause( api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
rich.print(api.set_playing(False), file=sys.stderr)
@cli.command(help="Goto next item in playlist")
def skip( api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
rich.print(api.playlist_next(), file=sys.stderr)
@cli.command(help="Goto previous item in playlist")
def prev( api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
rich.print(api.playlist_previous(), file=sys.stderr)
@cli.command(help="Goto a specific item in the playlist")
def goto( index: int, api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
rich.print(api.playlist_goto(index), file=sys.stderr)
@cli.command(help="Shuffle the playlist")
def shuf( api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
rich.print(api.playlist_shuffle(), file=sys.stderr)
@cli.command(help="Clear the playlist")
def clear( api_base: str = DEFAULT_API_BASE ):
api.set_endpoint(api_base)
rich.print(api.playlist_clear(), file=sys.stderr)
@cli.command(help="Get current status")
def status(
api_base: str = DEFAULT_API_BASE,
):
api.set_endpoint(api_base)
status = dict(
playing = api.is_playing,
volume = api.get_volume,
is_looping = api.get_playlist_looping,
playback_pos = api.get_playback_pos,
current = lambda: [i for i in api.get_playlist() if i.get("current", False)][:1] or [None],
)
with ThreadPoolExecutor() as p:
status = dict(zip(status.keys(), p.map(lambda x: x(), status.values())))
print_json(status)
@cli.command(help="Set the playback volume")
def set_volume(
volume: int,
api_base: str = DEFAULT_API_BASE,
):
api.set_endpoint(api_base)
rich.print(api.set_volume(volume), file=sys.stderr)
if __name__ == "__main__":
cli()

View File

@@ -0,0 +1,23 @@
/* https://textual.textualize.io/guide/design/#theme-reference */
Screen {
align: center top;
box-sizing: border-box;
}
Horizontal {
height: auto;
}
#header {
height: 1;
overflow: hidden;
}
#controls {
background: $background-lighten-2;
align: center middle;
}
#volumebar {
}
#seekbar {
}
DataTable#playlist {
width: 100%;
}

View File

@@ -320,6 +320,16 @@ class RemiApp(App):
playlist = api.get_playlist() # json structure
N = len(playlist)
start_ellipsis = False
end_ellipsis = False
if N > 100:
current, *_ = *(i for i, playlist_item in enumerate(playlist) if playlist_item.get("current", False)), None
if current is not None:
playlist = playlist[max(0, current - 50) : max(current+50, 100)]
start_ellipsis = current - 50 > 0
end_ellipsis = max(current+50, 100) < N
# update playlist table content:
table = []
for playlist_item in playlist:
@@ -340,6 +350,11 @@ class RemiApp(App):
icons.TRASH,
])
if start_ellipsis:
table.insert(0, ["", f"...{current - 50} more ...", "", "", "", "", ""])
if end_ellipsis:
table.append(["", f"...{N - current - 50} more ...", "", "", "", "", ""])
this_playlist = list(zip(table, [i.get("current", False) for i in playlist])) # ew, but it works...
if this_playlist == self.old_playlist: return
self.old_playlist = this_playlist
@@ -349,7 +364,7 @@ class RemiApp(App):
# styling the new table:
# for each row element:
for row_key, playlist_item in zip(self.playlist.table._render_children_list[1:], playlist):
for row_key, playlist_item in zip(self.playlist.table._render_children_list[1:][1 if start_ellipsis else 0 : -1 if end_ellipsis else None], playlist):
row_widget = self.playlist.table.get_child(row_key)
row_widget.set_on_click_listener(self.on_table_row_click, playlist_item)
@@ -399,6 +414,7 @@ class RemiApp(App):
#print(index, key, item_widget)
def set_playing(self, is_playing:bool): # Only updates GUI elements!
self.playback.play.set_text(icons.PAUSE if is_playing else icons.PLAY)
self.playback.seek_slider.set_enabled(is_playing)

172
grzegorz_clients/tui.py Normal file
View File

@@ -0,0 +1,172 @@
from . import api, utils
from pathlib import Path
from datetime import timedelta
import json
import os
import rich
import shutil
import subprocess
import sys
import typer
from textual.app import App, ComposeResult
from textual.containers import Vertical, Horizontal, VerticalScroll
from textual.widgets import Button, Static, ProgressBar, DataTable
from textual.timer import Timer
from textual import on
from textual.app import App, ComposeResult
import textual.containers as c
from textual.reactive import var
import textual.widgets as w
from textual.events import Mount
from textual.widgets import DirectoryTree, Footer, Header, Static
# export GRZEGORZ_DEFAULT_API_BASE="https://brzeczyszczykiewicz.pvv.ntnu.no/api"
# export GRZEGORZ_DEFAULT_API_BASE="https://georg.pvv.ntnu.no/api"
DEFAULT_API_BASE = os.environ.get("GRZEGORZ_DEFAULT_API_BASE", "https://brzeczyszczykiewicz.pvv.ntnu.no/api")
# url input
# prev - Button
# play/pause - Button
# next - Button
# loop - Switch
# shuffle - Button
# clear - Button
# position - ProgressBar
# playlist - datatable
# waveform - Sparkline
# LoadingIndicator
class GrzegorzApp(App):
CSS_PATH = "gzregorz.tcss"
#DEFAULT_CSS = ""
BINDINGS = [
("q", "quit", "Quit"),
]
refresh_timer: Timer
def compose(self) -> ComposeResult:
yield Static("Now playing: ", id="header")
yield Horizontal(
ProgressBar(id="seekbar", total=0, show_percentage=False, show_eta=False),
Static(" --:-- --:--", id="playtime"),
classes="center"
)
yield Horizontal(
ProgressBar(id="volumebar", total=100, show_eta=False),
classes="center"
)
yield Horizontal(
Button.error("Clear", id="btn-clear"),
Button("Prev", id="btn-prev"),
Button.success("Play", id="btn-play"),
Button("Next", variant="primary", id="btn-next"),
Button.warning("Shuffle", id="btn-shuffle"),
id="controls",
)
yield VerticalScroll(
DataTable(
id="playlist",
zebra_stripes=True,
cursor_type="row",
),
)
def on_mount(self) -> None:
api.set_endpoint("https://georg.pvv.ntnu.no/api")
self.refresh_timer = self.set_interval(1 / 1, self.do_update) # threaded
# self.log(api.get_playlist())
playlist: DataTable = self.query_one("#playlist")
playlist.add_columns("#", "Name", "length")
def do_update(self):
self.log("do_update")
# update status
vol = api.get_volume()
pos = api.get_playback_pos() or {"current": 0, "left": 0, "total": 0}
loop = api.get_playlist_looping()
play = api.is_playing()
self.log(f"""
{vol = }
{pos = }
{loop = }
{play = }
""")
# todo: sliders?
seek_var: ProgressBar = self.query_one("#seekbar")
seek_var.total = pos.get("total", 0)
seek_var.progress = pos.get("current", 0)
playtime: Static = self.query_one("#playtime")
playtime.update(f" --:-- - --:--" if not play else f" {timedelta(seconds=int(pos.get('current', 0)))} - {timedelta(seconds=int(pos.get('total', 0)))}")
vol_var: ProgressBar = self.query_one("#volumebar")
vol_var.progress = vol
btn_play: Button = self.query_one("#btn-play")
btn_play.label = "Pause" if play else "Play"
# btn_play.variant = "success" if play else "default"
# update playlist
playlist_data = api.get_playlist()
table = [
(
item.get("index", -1),
item.get("data", {}).get("title", None) or item["filename"],
utils.seconds_to_timestamp(item["data"]["duration"]) if "duration" in item.get("data", {}) else "--:--",
)
for item in playlist_data
]
current, = [item for item in playlist_data if item.get("current", False)][:1] or [None]
if current is not None:
self.query_one("#header").update("Now playing: " +
current.get("data", {}).get("title", None) or current["filename"]
)
playlist: DataTable = self.query_one("#playlist")
for r, row in enumerate(table[:playlist.row_count]):
for c, col in enumerate(row):
playlist.update_cell_at((r, c), col)
if playlist.row_count < len(table): # add more rows
playlist.add_rows(table[playlist.row_count:])
elif playlist.row_count > len(table): # remove extra rows
for i in range(playlist.row_count-1, len(table)-1, -1):
playlist.remove_row(playlist._row_locations.get_key(i))
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-clear":
api.playlist_clear()
elif event.button.id == "btn-prev":
api.playlist_previous()
elif event.button.id == "btn-play":
api.set_playing(not api.is_playing())
elif event.button.id == "btn-next":
api.playlist_next()
elif event.button.id == "btn-shuffle":
api.playlist_shuffle()
@on(DataTable.RowSelected)
def on_data_table_row_selected(self, event: DataTable.RowSelected):
self.log("spismeg")
self.log(f"{event = }")
self.log(f"{event.cursor_row = }")
self.log(f"{event.row_key = }")
self.log("spismeg")
def main():
app = GrzegorzApp()
app.run()
if __name__ == "__main__":
main()

1521
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,22 @@ authors = ["Peder Bergebakken Sundt <pbsds@hotmail.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = ">=3.7,<4.0"
python = ">=3.8.1,<4.0"
remi = "1.0"
requests = ">=2.27.1,<3"
rich = ">=13.3.5"
typer = ">=0.4.0"
urllib3 = "^1.26.18"
urllib3 = ">=1.26.8,<3"
textual = {version = "^0.61.0", extras = ["tui"]}
[tool.poetry.dev-dependencies]
python-lsp-server = {extras = ["all"], version = "^1.5.0"}
[tool.poetry.group.dev.dependencies]
python-lsp-server = {extras = ["all"], version = "^1.11.0"}
textual-dev = "^1.5.1"
[tool.poetry.scripts]
grzegorz-webui = "grzegorz_clients.__main__:cli"
grzegorzctl = "grzegorz_clients.cli:cli"
grzegorztui = "grzegorz_clients.tui:main"
[build-system]
requires = ["poetry-core>=1.0.0"]