Compare commits

...

19 Commits

Author SHA1 Message Date
Oystein Kristoffer Tveit cb292a56b1 Merge pull request 'webui: handle playback_pos response with empty contents' (#13) from handle-empty-playback-pos-response into master
Reviewed-on: #13
Reviewed-by: Peder Bergebakken Sundt <pederbs@pvv.ntnu.no>
2024-08-05 21:16:57 +02:00
Oystein Kristoffer Tveit 132cabf434
webui: handle playback_pos response with empty contents 2024-08-05 17:53:15 +02:00
Daniel Lovbrotte Olsen 4617f83f5c Merge pull request 'cli: fix relative volume adjustment' (#12) from fix-new-cli-features into master
Reviewed-on: #12
2024-06-02 02:38:17 +02:00
Daniel Lovbrotte Olsen 047d09ffb1 cli: fix relative volume adjustment 2024-05-25 23:58:08 +02:00
Peder Bergebakken Sundt b9444658fb
Merge pull request #11 from Programvareverkstedet/more_cli_features
More stuff please
2024-05-19 12:48:15 +02:00
Daniel Lovbrotte Olsen 0c45cbe1f6 flake: add grzegorzctl-only package which installs shell-completions 2024-05-19 06:39:23 +02:00
Daniel Lovbrotte Olsen ca78aa9e22 cli: allow relative volume adjustment using {+,-}<n>{,%} 2024-05-19 06:15:54 +02:00
Daniel Lovbrotte Olsen 2b8ecf124d cli: add function to toggle playback status 2024-05-19 06:15:54 +02:00
Daniel Lovbrotte Olsen 1009b29995 cli: fix spelling error in queue adding commands 2024-05-19 06:07:52 +02:00
Peder Bergebakken Sundt e00ae9e344 bruh 2024-05-18 19:02:18 +02:00
Peder Bergebakken Sundt 0536a56ca6
Merge pull request #10 from Programvareverkstedet/you-happy-now-daniel
Rework grzegorzctl interface
2024-05-18 00:28:27 +02:00
Peder Bergebakken Sundt a9e8330898 Rework grzegorzctl interface 2024-05-18 00:10:26 +02:00
Oystein Kristoffer Tveit 3059898e38
Improve error messages produced by http decorators 2024-05-17 23:40:42 +02:00
Peder Bergebakken Sundt 738a4f3dd8 webui: view slice if len(playlist) > 100 2024-05-11 01:44:11 +02:00
Peder Bergebakken Sundt 7e8baa0a48 lkjlkjdsalkj 2024-05-10 20:04:30 +02:00
Peder Bergebakken Sundt f3cbb43f91 poetry lock
dependabot bad
2024-05-10 18:52:27 +02:00
Peder Bergebakken Sundt c38f2f22a6 lock 2024-03-31 04:48:21 +02:00
Peder Bergebakken Sundt b23b02b5e8 grzegorzctl 2024-03-31 04:48:14 +02:00
Peder Bergebakken Sundt 99f41e54c4 flake shell 2024-03-31 04:45:22 +02:00
11 changed files with 908 additions and 463 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

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,15 +55,31 @@
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;
grzegorzctl = pkgs.runCommandNoCCLocal "grzegorzctl" (
{
nativeBuildInputs = [ pkgs.installShellFiles ];
} //
{ inherit (flakes.self.pkgs.grzegorz-clients) meta; } //
{ meta.mainProgram = "grzegorzctl"; }
)''
mkdir -p $out/bin
ln -s "${flakes.self.pkgs.grzegorz-clients}/bin/grzegorzctl" $out/bin/grzegorzctl
installShellCompletion --cmd grzegorzctl \
--bash <($out/bin/grzegorzctl --show-completion bash) \
--zsh <($out/bin/grzegorzctl --show-completion zsh) \
--fish <($out/bin/grzegorzctl --show-completion fish)
'';
default = flakes.self.pkgs.grzegorzctl;
});
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}.grzegorzctl}/bin/grzegorzctl";
default = grzegorzctl;
});
nixosModules.grzegorz-webui = { config, pkgs, ... }: let
@ -126,5 +144,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"

181
grzegorz_clients/cli.py Normal file
View File

@ -0,0 +1,181 @@
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 or 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 or 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 or 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="Toggle playback")
def toggle(api_base: str = DEFAULT_API_BASE):
api.set_endpoint(api_base)
playing = api.is_playing()
rich.print(api.set_playing(not playing), 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: str,
api_base: str = DEFAULT_API_BASE,
):
api.set_endpoint(api_base)
volume = volume.removesuffix("%")
if volume.startswith("+") or volume.startswith("-"):
current_volume = api.get_volume()
new_volume = max(0, min(100, current_volume + int(volume)))
new_volume = int(new_volume)
else:
new_volume = int(volume)
rich.print(api.set_volume(new_volume), file=sys.stderr)
if __name__ == "__main__":
cli()

View File

@ -294,7 +294,7 @@ class RemiApp(App):
except api.APIError:
playback_pos = None
if playback_pos:
if playback_pos and isinstance(playback_pos, dict) and playback_pos["current"] and playback_pos["total"]:
slider_pos = playback_pos["current"] / playback_pos["total"] * 100
current = seconds_to_timestamp(playback_pos["current"])
total = seconds_to_timestamp(playback_pos["total"])
@ -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)

1026
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,14 +9,16 @@ license = "MIT"
python = ">=3.7,<4.0"
remi = "1.0"
requests = ">=2.27.1,<3"
rich = ">=13.3.5"
typer = ">=0.4.0"
urllib3 = "^1.26.8"
urllib3 = ">=1.26.8,<3"
[tool.poetry.dev-dependencies]
python-lsp-server = {extras = ["all"], version = "^1.5.0"}
[tool.poetry.scripts]
grzegorz-webui = "grzegorz_clients.__main__:cli"
grzegorzctl = "grzegorz_clients.cli:cli"
[build-system]
requires = ["poetry-core>=1.0.0"]