Compare commits

..

No commits in common. "master" and "mpv-control" have entirely different histories.

25 changed files with 67 additions and 2814 deletions

28
.envrc
View File

@ -1,28 +0,0 @@
#!/usr/bin/env bash
# This file is loaded with `direnv`.
# It enters you into the poetry venv, removing the need for `poetry run`.
if command -v nix >/dev/null; then
use flake
fi
export GRZEGORZ_IS_DEBUG=1 # mpv does not start in fullscreen
# Instead of using the flake, we use poetry to manage a development venv
# We only use poetry2nix for deployment
# create venv if it doesn't exist
poetry run true
# enter venv
export VIRTUAL_ENV=$(poetry env info --path)
export POETRY_ACTIVE=1
PATH_add "$VIRTUAL_ENV/bin"
if ! command -v sanic >/dev/null; then
poetry install
# patchelf the venv on nixos
if ! test -s /lib64/ld-linux-x86-64.so.2 || { uname -a | grep -qi nixos; }; then
#nix run github:GuillaumeDesforges/fix-python -- --venv "$VIRTUAL_ENV" #--libs .nix/libs.nix
fix-python --venv "$VIRTUAL_ENV" #--libs .nix/libs.nix
fi
fi

6
.gitignore vendored
View File

@ -1,7 +1 @@
*.pyc
__pycache__
config.py
*.socket
result
result-*
.direnv

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "python-mpv"]
path = lib/python_mpv
url = https://github.com/gustaebel/python-mpv.git

View File

@ -1,46 +0,0 @@
# Grzegorz API
<img align="right" width="250" src="grzegorz/res/logo.png">
`grzegorz` is simple REST API for managing an instance of MPV.
Why "Grzegorz"? [Great taste in humor of course!](https://youtu.be/t-fcrn1Edik)
When `grzegorz` starts, it launches an instance of MPV and maintains it. It is designed to be used as an info screen or HTPC, and supports multiple users to push changes to the MPV instance.
The API is described and can be tested at `http:/localhost:8080/docs/swagger` while the server is running. All API endpoints are available under `/api`
## How install and run it
Gregorz manages a MPV process, meaning you need to have MPV installed on your system. Look for it in your package manager.
sudo pip install git+https://git.pvv.ntnu.no/Grzegorz/grzegorz#master
sanic grzegorz.app --host :: --port 8080
Details are over [here](https://sanic.dev/en/guide/deployment/running.html#running-via-command).
## Development server
Setup local virtual environment and run with auto-reload:
poetry install
poetry run sanic grzegorz.app --host localhost --port 8000 --debug
The server should now be available at `http://localhost:8000/`.
## A word of caution
Grzegors will make a unix socket in the current working directory. Make sure it is somewhere writeable!
## Making `grzegorz` run on boot
<!-- TODO: make this use Cage or xinit -->
When setting up a info screen or HTPC using Grzegors, you may configure it to run automatically on startup.
We recommend installing a headless linux, and create a user for `grzegorz` to run as. (We named ours `grzegorz`, obviously)
Clone this repo into the home directory. Then make systemd automatically spin up a X session to run `grzegorz` in: Copy the files in the folder `dist` into the folder `$HOME/.config/systemd/user` and run the following commands as your user:
$ systemctl --user enable grzegorz@0.service
$ systemctl --user start grzegorz@0.service

View File

@ -1,34 +0,0 @@
#!/usr/bin/env bash
# My little crappy deploy script
# Uploads all files not ignored by git
TARGET=grzegorz@brzeczyszczykiewicz.pvv.ntnu.no
TARGET_PATH='grzegorz'
array=(); while IFS= read -rd '' item; do array+=("$item"); done < \
<(git status -z --short | grep -z ^? | cut -z -d\ -f2-; git ls-files -z)
files_not_ignored=("${array[@]}")
ssh -T "$TARGET" "
mv -v '$TARGET_PATH/config.py' /tmp/grzegorz_config.py
rm -rfv $TARGET_PATH
mkdir -pv $TARGET_PATH
mv -v /tmp/grzegorz_config.py '$TARGET_PATH/config.py'
"
echo '== Copying files to target: =='
tar -c "${files_not_ignored[@]}" |
ssh -T "$TARGET" "
tar -vxC $TARGET_PATH
"
echo '== DONE: =='
ssh -T "$TARGET" "
systemctl --user restart grzegorz@0
"
sleep 1
ssh -T "$TARGET" "
systemctl --user status grzegorz@0
"

View File

@ -1,14 +0,0 @@
[Unit]
Description=Grzegorz at display %i
Requires=xorg@%i.socket
Requires=xorg@%i.service
After=xorg@%i.socket
After=xorg@%i.service
[Service]
Environment="DISPLAY=:%i"
ExecStart=/usr/bin/python grzegorz/main.py
[Install]
WantedBy=default.target

9
dist/xorg@.service vendored
View File

@ -1,9 +0,0 @@
[Unit]
Description=Xorg server at display %i
Requires=xorg@%i.socket
After=xorg@%i.socket
[Service]
ExecStart=/usr/bin/Xorg :%i -nolisten tcp -noreset -verbose 2 "vt${XDG_VTNR}"
SuccessExitStatus=0 1

8
dist/xorg@.socket vendored
View File

@ -1,8 +0,0 @@
[Unit]
Description=Socket for xorg at display %i
[Socket]
ListenStream=/tmp/.X11-unix/X%i
[Install]
WantedBy=sockets.target

81
flake.lock generated
View File

@ -1,81 +0,0 @@
{
"nodes": {
"fix-python": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1725463969,
"narHash": "sha256-d3c1TAlIN1PtK+oQP1wO6XbDfmR4SUp/C/4s7G46ARo=",
"owner": "GuillaumeDesforges",
"repo": "fix-python",
"rev": "2926402234c3f99aa8e4608c51d9ffa73ea403c0",
"type": "github"
},
"original": {
"owner": "GuillaumeDesforges",
"repo": "fix-python",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1726755586,
"narHash": "sha256-PmUr/2GQGvFTIJ6/Tvsins7Q43KTMvMFhvG6oaYK+Wk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c04d5652cfa9742b1d519688f65d1bbccea9eb7e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fix-python": "fix-python",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

142
flake.nix
View File

@ -1,142 +0,0 @@
{
description = "A REST API for managing a MPV instance over via a RPC socket";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.fix-python.url = "github:GuillaumeDesforges/fix-python";
inputs.fix-python.inputs.nixpkgs.follows = "nixpkgs";
#inputs.fix-python.inputs.flake-utils.follows = "flake-utils";
outputs = {
self,
nixpkgs,
fix-python,
...
} @ inputs:
let
forSystems = systems: f: nixpkgs.lib.genAttrs systems (system: f rec {
inherit system;
pkgs = nixpkgs.legacyPackages.${system};
lib = nixpkgs.legacyPackages.${system}.lib;
});
forAllSystems = forSystems [
"x86_64-linux"
"aarch64-linux"
#"riscv64-linux"
];
in {
packages = forAllSystems ({ system, pkgs, ...}: rec {
sanic-ext = with pkgs.python3.pkgs; buildPythonPackage rec {
pname = "sanic-ext";
version = "23.12.0";
src = fetchPypi {
inherit pname version;
hash = "sha256-QvxB5/r6WPO3kPaF892KjeKBRgtBadDpH04RuHR/hFw=";
};
propagatedBuildInputs = [ pyyaml ];
doCheck = false;
};
grzegorz = with pkgs.python3.pkgs; buildPythonPackage {
pname = "grzegorz";
version = (builtins.fromTOML (builtins.readFile ./pyproject.toml)).tool.poetry.version;
format = "pyproject";
src = ./.;
postInstall = ''
'';
nativeBuildInputs = [ poetry-core ];
propagatedBuildInputs = [ setuptools sanic sanic-ext yt-dlp mpv ];
doCheck = false;
};
grzegorz-run = pkgs.writeShellApplication {
name = "grzegorz-run";
runtimeInputs = [ (pkgs.python3.withPackages (ps: [ grzegorz ])) pkgs.mpv ];
text = ''
TOOMANYARGS=()
if test -z "$*"; then
>&2 echo "DEBUG: No args provided, running with defaults...."
TOOMANYARGS=("--host" "::" "--port" "8080")
fi
(
set -x
sanic grzegorz.app "''${TOOMANYARGS[@]}" "$@"
)
'';
};
default = grzegorz;
});
apps = forAllSystems ({ system, pkgs, ...}: {
default.type = "app";
default.program = "${self.packages.${system}.grzegorz-run}/bin/grzegorz-run";
});
devShells = forAllSystems ({ system, pkgs, ...}: rec {
default = pkgs.mkShellNoCC {
packages = with pkgs; [
poetry
python3
# mpv # myust be in sync with system glibc
fix-python.packages.${system}.default
];
shellHook = let
inherit (pkgs) lib;
libs = [
pkgs.stdenv.cc.cc.lib # dlopen libstdc++
#pkgs.glibc
#pkgs.zlib
];
addPath = var: paths: subdir: ''
export ${var}=${lib.makeSearchPath subdir libs}"''${${var}:+:$${var}}"
'';
in ''
export NIX_PATH=nixpkgs=${nixpkgs}"''${NIX_PATH:+:$NIX_PATH}"
${addPath "CPATH" libs "include"}
${addPath "LD_LIBRARY_PATH" libs "lib"}
'';
};
});
nixosModules.grzegorz-kiosk = { config, pkgs, ... }: let
inherit (pkgs) lib;
cfg = config.services.grzegorz;
in {
options.services.grzegorz = {
enable = lib.mkEnableOption (lib.mdDoc "grzegorz");
package = lib.mkPackageOption self.packages.${config.nixpkgs.system} "grzegorz-run" { };
listenAddr = lib.mkOption {
type = lib.types.str;
default = "::";
};
listenPort = lib.mkOption {
type = lib.types.port;
default = 9090;
};
};
config = {
services.cage.enable = true;
services.cage.program = pkgs.writeShellScript "grzegorz-kiosk" ''
cd $(mktemp -d)
${(lib.escapeShellArgs [
"${cfg.package}/bin/grzegorz-run"
"--host" cfg.listenAddr
"--port" cfg.listenPort
])}
'';
services.cage.user = "grzegorz";
users.users."grzegorz".isNormalUser = true;
system.activationScripts = {
base-dirs = {
text = ''
mkdir -p /nix/var/nix/profiles/per-user/grzegorz
'';
deps = [];
};
};
};
};
};
}

View File

@ -1,47 +0,0 @@
from sanic import Sanic
from pathlib import Path
import traceback
from . import mpv
from . import api
__all__ = ["app"]
module_name = __name__.split(".", 1)[0]
app = Sanic(module_name, env_prefix=module_name.upper() + '_')
# api
app.blueprint(api.bp, url_prefix="/api")
app.add_task(api.PLAYLIST_DATA_CACHE.run())
# openapi:
app.ext.openapi.describe("Grzegorz API",
version = "1.0.0.",
description = "The Grzegorz Brzeczyszczykiewicz API, used to control a running mpv instance",
)
# mpv:
async def runMPVControl():
app.config["mpv_control"] = mpv.MPVControl()
try:
await app.config["mpv_control"].run()
except:
traceback.print_exc()
finally:
pass #app.stop()
app.add_task(runMPVControl())
# populate playlist
async def ensure_splash():
here = Path(__file__).parent.resolve()
while not "mpv_control" in app.config:
await None
mpv_control: mpv.MPVControl = app.config["mpv_control"]
playlist = await mpv_control.playlist_get()
if len(playlist) == 0:
print("Adding splash to playlist...")
await mpv_control.loadfile(here / "res/logo.jpg")
app.add_task(ensure_splash())

View File

@ -1,221 +0,0 @@
from sanic import Request, Blueprint, response
from sanic_ext import openapi
from functools import wraps
from .mpv import MPVControl
from .playlist_data import PlaylistDataCache
bp = Blueprint("grzegorz-api", strict_slashes=True)
# this blueprint assumes a mpv.MPVControl instance is available
# at request.app.config["mpv_control"]
#route decorators:
def response_json(func):
@wraps(func)
async def newfunc(*args, **kwargs):
try:
request = args[0]
mpv_control = request.app.config["mpv_control"]
out = await func(*args, mpv_control, **kwargs)
if "error" not in out:
out["error"] = False
if "request" in out:
del out["request"]
if "mpv_control" in out:
del out["mpv_control"]
return response.json(out)
except Exception as e:
return response.json({
"error": e.__class__.__name__,
"errortext": str(e)
})
return newfunc
def response_text(func):
@wraps(func)
async def newfunc(*args, **kwargs):
body = await func(*args, **kwargs)
return response.text(body)
return newfunc
class APIError(Exception):
pass
# singleton
PLAYLIST_DATA_CACHE = PlaylistDataCache(auto_fetch_data=True)
#routes:
@bp.get("")
@openapi.exclude(True)
@response_text
async def root(request: Request):
return "Hello friend, I hope you're having a lovely day\n"
@bp.post("/load")
@openapi.summary("Add item to playlist")
@openapi.parameter("path", openapi.String(description="Link to the resource to enqueue"), required=True)
@openapi.parameter("body", openapi.Object(description="Any data you want stored with the queued item"), location="body")
@response_json
async def loadfile(request: Request, mpv_control: MPVControl):
if "path" not in request.args:
raise APIError("No query parameter \"path\" provided")
if request.json:
PLAYLIST_DATA_CACHE.add_data(request.args["path"][0], request.json)
success = await mpv_control.loadfile(request.args["path"][0])
return locals()
@bp.get("/play")
@openapi.summary("Check whether the player is paused or playing")
@response_json
async def play_get(request: Request, mpv_control: MPVControl):
value = await mpv_control.pause_get() == False
return locals()
@bp.post("/play")
@openapi.summary("Set whether the player is paused or playing")
@openapi.parameter("play", openapi.Boolean(description="Whether to be playing or not"))
@response_json
async def play_set(request: Request, mpv_control: MPVControl):
if "play" not in request.args:
raise APIError("No query parameter \"play\" provided")
success = await mpv_control \
.pause_set(str(request.args["play"][0]).lower() not in ["true", "1"])
return locals()
@bp.get("/volume")
@openapi.summary("Get the current player volume")
@response_json
async def volume_get(request: Request, mpv_control: MPVControl):
value = await mpv_control.volume_get()
return locals()
@bp.post("/volume")
@openapi.summary("Set the player volume")
@openapi.parameter("volume", openapi.Integer(description="A number between 0 and 100"))
@response_json
async def volume_set(request: Request, mpv_control: MPVControl):
if "volume" not in request.args:
raise APIError("No query parameter \"volume\" provided")
success = await mpv_control \
.volume_set(int(request.args["volume"][0]))
return locals()
@bp.get("/time")
@openapi.summary("Get current playback position")
@response_json
async def time_get(request: Request, mpv_control: MPVControl):
value = {
"current": await mpv_control.time_pos_get(),
"left": await mpv_control.time_remaining_get(),
}
value["total"] = value["current"] + value["left"]
return locals()
@bp.post("/time")
@openapi.summary("Set playback position")
@openapi.parameter("pos", openapi.Float(description="Seconds to seek to"))
@openapi.parameter("percent", openapi.Integer(description="Percent to seek to"))
@response_json
async def time_set(request: Request, mpv_control: MPVControl):
if "pos" in request.args:
success = await mpv_control.seek_absolute(float(request.args["pos"][0]))
elif "percent" in request.args:
success = await mpv_control.seek_percent(int(request.args["percent"][0]))
else:
raise APIError("No query parameter \"pos\" or \"percent\"provided")
return locals()
@bp.get("/playlist")
@openapi.summary("Get the current playlist")
@response_json
async def playlist_get(request: Request, mpv_control: MPVControl):
value = await mpv_control.playlist_get()
value = list(PLAYLIST_DATA_CACHE.add_data_to_playlist(value))
for i, v in enumerate(value):
v["index"] = i
if "current" in v and v["current"] == True:
v["playing"] = await mpv_control.pause_get() == False
if value: del i, v
return locals()
@bp.post("/playlist/next")
@openapi.summary("Skip to the next item in the playlist")
@response_json
async def playlist_next(request: Request, mpv_control: MPVControl):
success = await mpv_control.playlist_next()
return locals()
@bp.post("/playlist/previous")
@openapi.summary("Go back to the previous item in the playlist")
@response_json
async def playlist_previous(request: Request, mpv_control: MPVControl):
success = await mpv_control.playlist_prev()
return locals()
@bp.post("/playlist/goto")
@openapi.summary("Go chosen item in the playlist")
@openapi.parameter("index", openapi.Integer(description="The 0 indexed playlist item to go to"), required=True)
@response_json
async def playlist_goto(request: Request, mpv_control: MPVControl):
if "index" not in request.args:
raise APIError("Missing the required parameter: \"index\"")
success = await mpv_control.playlist_goto(
int(request.args["index"][0]))
return locals()
@bp.delete("/playlist")
@openapi.summary("Clears single item or whole playlist")
@openapi.parameter("index", openapi.Integer(description="Index to item in playlist to remove. If unset, the whole playlist is cleared"))
@response_json
async def playlist_remove_or_clear(request: Request, mpv_control: MPVControl):
if "index" in request.args:
success = await mpv_control.playlist_remove(int(request.args["index"][0]))
action = f"remove #{request.args['index'][0]}"
else:
success = await mpv_control.playlist_clear()
action = "clear all"
return locals()
@bp.post("/playlist/move")
@openapi.summary("Move playlist item to new position")
@openapi.description(
"Move the playlist entry at index1, so that it takes the "
"place of the entry index2. (Paradoxically, the moved playlist "
"entry will not have the index value index2 after moving if index1 "
"was lower than index2, because index2 refers to the target entry, "
"not the index the entry will have after moving.)")
@openapi.parameter("index2", int, required=True)
@openapi.parameter("index1", int, required=True)
@response_json
async def playlist_move(request: Request, mpv_control: MPVControl):
if "index1" not in request.args or "index2" not in request.args:
raise APIError(
"Missing at least one of the required query "
"parameters: \"index1\" and \"index2\"")
success = await mpv_control.playlist_move(
int(request.args["index1"][0]),
int(request.args["index2"][0]))
return locals()
@bp.post("/playlist/shuffle")
@openapi.summary("Clears single item or whole playlist")
@response_json
async def playlist_shuffle(request: Request, mpv_control: MPVControl):
success = await mpv_control.playlist_shuffle()
return locals()
@bp.get("/playlist/loop")
@openapi.summary("See whether it loops the playlist or not")
@response_json
async def playlist_get_looping(request: Request, mpv_control: MPVControl):
value = await mpv_control.playlist_get_looping()
return locals()
@bp.post("/playlist/loop")
@openapi.summary("Sets whether to loop the playlist or not")
@openapi.parameter("loop", openapi.Boolean(description="Whether to be looping or not"), required=True)
@response_json
async def playlist_set_looping(request: Request, mpv_control: MPVControl):
if "loop" not in request.args:
raise APIError("Missing the required parameter: \"loop\"")
success = await mpv_control.playlist_set_looping(
request.args["loop"][0].lower() in ("1", "true", "on", "inf"))
return locals()

View File

@ -1,54 +0,0 @@
from urllib.parse import urlsplit, urlunsplit, parse_qs, urlencode
import yt_dlp as youtube_dl
from yt_dlp.utils import DownloadError
from . import nyasync
@nyasync.ify
def title(url):
ydl = youtube_dl.YoutubeDL()
return ydl.extract_info(url, download=False).get('title')
def filter_query_params(url, allowed=[]):
split_url = urlsplit(url)
qs = parse_qs(split_url.query)
print(qs)
for key in list(qs.keys()):
if key not in allowed:
del qs[key]
return urlunsplit((
split_url.scheme,
split_url.netloc,
split_url.path,
urlencode(qs, doseq=True),
split_url.fragment,
))
@nyasync.ify
def get_youtube_dl_metadata(url, ydl = youtube_dl.YoutubeDL()):
if urlsplit(url).scheme == "":
return None
if urlsplit(url).netloc.lower() in ("www.youtube.com", "youtube.com", "youtu.be"):
#Stop it from doing the whole playlist
url = filter_query_params(url, allowed=["v"])
elif urlsplit(url).scheme == "ytdl":
url = f"https://youtube.com/watch?v={urlsplit(url).netloc}"
try:
resp = ydl.extract_info(url, download=False)
except DownloadError:
return None
#filter and return:
return {k:v for k, v in resp.items() if k in
("uploader", "title", "thumbnail", "description", "duration")}
async def get_metadata(url):
data = await get_youtube_dl_metadata(url)
if data is None:
# (TODO): local ID3 tags
return {"failed": True}
return data

View File

@ -1,261 +0,0 @@
import os
import asyncio
import json
import time
import shlex
import traceback
from typing import List, Optional, Union
from pathlib import Path
from . import nyasync
IS_DEBUG = os.environ.get("GRZEGORZ_IS_DEBUG", "0") != "0"
class MPV:
# TODO: move this to /tmp or /var/run ?
# TODO: make it configurable with an env variable?
_ipc_endpoint = Path(f"mpv_ipc.socket")
def __init__(self):
self.requests = nyasync.Queue()
self.responses = nyasync.Queue()
self.events = nyasync.Queue()
@classmethod
def mpv_command(cls) -> List[str]:
return [
'mpv',
f'--input-ipc-server={str(cls._ipc_endpoint)}',
'--idle',
'--force-window',
*(('--fullscreen',) if not IS_DEBUG else ()),
'--ytdl-format=bestvideo[height<=?1080]',
'--no-terminal',
'--load-unsafe-playlists',
'--keep-open', # Keep last frame of video on end of video
#'--no-input-default-bindings',
]
async def run(self, is_restarted=False, **kw):
if self._ipc_endpoint.is_socket():
print("Socket found, try connecting instead of starting our own mpv!")
self.proc = None # we do not own the socket
await self.connect(**kw)
else:
print("Starting mpv...")
self.proc = await asyncio.create_subprocess_exec(*self.mpv_command())
await asyncio.gather(
self.ensure_running(),
self.connect(**kw),
)
async def connect(self, *, timeout=10):
await asyncio.sleep(0.5)
t = time.time()
while self.is_running and time.time() - t < timeout:
try:
self.ipc_conn = await nyasync.UnixConnection.from_path(str(self._ipc_endpoint))
break
except (FileNotFoundError, ConnectionRefusedError):
continue
await asyncio.sleep(0.1)
else:
if time.time() - t >= timeout:
#raise TimeoutError
# assume the socket is dead, and start our own instance
print("Socket not responding. Will try deleting it and start mpv ourselves!")
self._ipc_endpoint.unlink()
return await self.run()
else:
raise Exception("MPV died before socket connected")
print("Connected to mpv!")
# TODO: in this state we are unable to detect if the connection is lost
self._future_connect = asyncio.gather(
self.process_outgoing(),
self.process_incomming(),
)
await self._future_connect
def _cleanup_connection(self):
assert self.proc is not None # we must own the socket
self._future_connect.cancel() # reduces a lot of errors on exit
if self._ipc_endpoint.is_socket():
self._ipc_endpoint.unlink()
@property
def is_running(self) -> bool:
if self.proc is None: # we do not own the socket
# TODO: can i check the read and writer?
return self._ipc_endpoint.is_socket()
else:
return self.proc.returncode is None
async def ensure_running(self):
await self.proc.wait()
print("MPV suddenly stopped...")
self._cleanup_connection()
await self.run()
async def process_outgoing(self):
async for request in self.requests:
try:
encoded = json.dumps(request).encode('utf-8')
except Exception as e:
print("Unencodable request:", request)
traceback.print_exception(e)
continue
self.ipc_conn.write(encoded)
self.ipc_conn.write(b'\n')
async def process_incomming(self):
async for response in self.ipc_conn:
msg = json.loads(response)
if 'event' in msg:
self.events.put_nowait(msg)
else: # response
self.responses.put_nowait(msg)
class MPVError(Exception):
pass
class MPVControl:
def __init__(self):
self.mpv = MPV()
self.request_lock = asyncio.Lock()
async def run(self):
await asyncio.gather(
self.mpv.run(),
self.process_events(),
)
async def process_events(self):
async for event in self.mpv.events:
# TODO: print?
pass
async def send_request(self, msg):
async with self.request_lock:
# Note: If asyncio.Lock is FIFO, the put can be moved out of the
# critical section. If await is FIFO, the lock is unnessesary. This
# is the safest option.
self.mpv.requests.put_nowait(msg)
return await self.mpv.responses.get()
#other commands:
async def wake_screen(self):
# TODO: use this
# TODO: wayland counterpart
p = await asyncio.create_subprocess_exec(
"xset",
"-display",
os.environ["DISPLAY"],
"dpms",
"force",
"on"
)
code = await p.wait()
#Shorthand command requests:
async def loadfile(self, file: Union[str, Path]):
"appends to playlist and start playback if paused"
if isinstance(file, Path):
file = str(file)
resp = await self.send_request({"command":["loadfile", file, "append-play"]})
return resp["error"] == "success"
async def pause_get(self):
resp = await self.send_request({"command":["get_property", "pause"]})
if "error" in resp and resp["error"] != "success":
raise MPVError("Unable to get whether paused or not: " + resp["error"])
return resp["data"] if "data" in resp else None
async def pause_set(self, state: bool):
resp = await self.send_request({"command":["set_property", "pause", bool(state)]})
return resp["error"] == "success"
async def volume_get(self):
resp = await self.send_request({"command":["get_property", "volume"]})
if "error" in resp and resp["error"] != "success":
raise MPVError("Unable to get volume! " + resp["error"])
return resp["data"] if "data" in resp else None
async def volume_set(self, volume: int):
resp = await self.send_request({"command":["set_property", "volume", volume]})
return resp["error"] == "success"
async def time_pos_get(self):
resp = await self.send_request({"command":["get_property", "time-pos"]})
if "error" in resp and resp["error"] != "success":
raise MPVError("Unable to get time pos: " + resp["error"])
return resp["data"] if "data" in resp else None
async def time_remaining_get(self):
resp = await self.send_request({"command":["get_property", "time-remaining"]})
if "error" in resp and resp["error"] != "success":
raise MPVError("Unable to get time left:" + resp["error"])
return resp["data"] if "data" in resp else None
async def seek_absolute(self, seconds: float):
resp = await self.send_request({"command":["seek", seconds, "absolute"]})
return resp["data"] if "data" in resp else None
async def seek_relative(self, seconds: float):
resp = await self.send_request({"command":["seek", seconds, "relative"]})
return resp["data"] if "data" in resp else None
async def seek_percent(self, percent: float):
resp = await self.send_request({"command":["seek", percent, "absolute-percent"]})
return resp["data"] if "data" in resp else None
async def playlist_get(self):
resp = await self.send_request({"command":["get_property", "playlist"]})
if "error" in resp and resp["error"] != "success":
raise MPVError("Unable to get playlist:" + resp["error"])
return resp["data"] if "data" in resp else None
async def playlist_next(self):
resp = await self.send_request({"command":["playlist-next", "weak"]})
return resp["error"] == "success"
async def playlist_prev(self):
resp = await self.send_request({"command":["playlist-prev", "weak"]})
return resp["error"] == "success"
async def playlist_goto(self, index):
resp = await self.send_request({"command":["set_property", "playlist-pos", index]})
return resp["error"] == "success"
async def playlist_clear(self):
resp = await self.send_request({"command":["playlist-clear"]})
return resp["error"] == "success"
async def playlist_remove(self, index: Optional[int] = None):
resp = await self.send_request({"command":["playlist-remove", "current" if index==None else index]})
return resp["error"] == "success"
async def playlist_move(self, index1: int, index2: int):
resp = await self.send_request({"command":["playlist-move", index1, index2]})
return resp["error"] == "success"
async def playlist_shuffle(self):
resp = await self.send_request({"command":["playlist-shuffle"]})
return resp["error"] == "success"
async def playlist_get_looping(self):
resp = await self.send_request({"command":["get_property", "loop-playlist"]})
return resp["data"] == "inf" if "data" in resp else False
async def playlist_set_looping(self, value: bool):
resp = await self.send_request({"command":["set_property", "loop-playlist", "inf" if value else "no"]})
return resp["error"] == "success"
# CLI entrypoint
def print_mpv_command():
print(*map(shlex.quote, MPV.mpv_command()))

View File

@ -1,70 +0,0 @@
import asyncio
from asyncio.streams import StreamReader, StreamWriter
def ify(func):
"""Decorate func to run async in default executor"""
async def asyncified(*args, **kwargs):
asyncloop = asyncio.get_event_loop()
return await asyncloop.run_in_executor(
None, lambda: func(*args, **kwargs))
return asyncified
def safely(func, *args, **kwargs):
asyncloop = asyncio.get_event_loop()
asyncloop.call_soon_threadsafe(lambda: func(*args, **kwargs))
def callback(coro, callback=None):
asyncloop = asyncio.get_event_loop()
future = asyncio.run_cooroutine_threadsafe(coro, asyncloop)
if callback:
future.add_done_callback(callback)
return future
def run(*coros):
asyncloop = asyncio.get_event_loop()
return asyncloop.run_until_complete(asyncio.gather(*coros))
class Queue(asyncio.Queue):
__anext__ = asyncio.Queue.get
def __aiter__(self):
return self
class Event:
def __init__(self):
self.monitor = asyncio.Condition()
def __aiter__(self):
return self.monitor.wait()
async def notify(self):
with await self.monitor:
self.monitor.notify_all()
class Condition:
def __init__(self, predicate):
self.predicate = predicate
self.monitor = asyncio.Condition()
def __aiter__(self):
return self.monitor.wait_for(self.predicate)
async def notify(self):
with await self.monitor:
self.monitor.notify_all()
class UnixConnection:
def __init__(self, reader: StreamReader, writer: StreamWriter):
self.reader: StreamReader = reader
self.writer: StreamWriter = writer
@classmethod
async def from_path(cls, path):
endpoints = await asyncio.open_unix_connection(path, limit=2**24) # default is 2**16
return cls(*endpoints)
def __aiter__(self):
return self.reader.__aiter__() # readline
def write(self, data):
self.writer.write(data)

View File

@ -1,49 +0,0 @@
from .metadatafetch import get_metadata
from . import nyasync
#Used in api.playlist_get() and api.loadfile()
class PlaylistDataCache:
def __init__(self, auto_fetch_data = False):
self.filepath_data_map = {}
self.auto_fetch_data = auto_fetch_data
self.jobs = None
def add_data(self, filepath, data=None):
if data:
self.filepath_data_map[filepath] = data
async def run(self):
if not self.auto_fetch_data: return
self.jobs = nyasync.Queue()
async for filename in self.jobs:
print("Fetching metadata for ", repr(filename))
data = await get_metadata(filename)
#might already be gone by this point:
if filename in self.filepath_data_map:
self.filepath_data_map[filename].update(data)
del self.filepath_data_map[filename]["fetching"]
def add_data_to_playlist(self, playlist):
seen = set()
for item in playlist:
if "filename" in item:
seen.add(item["filename"])
if item["filename"] in self.filepath_data_map:
new_item = item.copy()
new_item["data"] = self.filepath_data_map[item["filename"]]
yield new_item
continue
elif self.auto_fetch_data:
self.filepath_data_map[item["filename"]] = {"fetching": True}
self.jobs.put_nowait(item["filename"])
new_item = item.copy()
new_item["data"] = {"fetching": True}
yield new_item
continue
yield item
not_seen = set(self.filepath_data_map.keys()) - seen
for name in not_seen:
del self.filepath_data_map[name]

View File

@ -1,34 +0,0 @@
import asyncio
from . import metadatafetch
from . import nyasync
metadatafetch_queue = nyasync.Queue()
async def metadatafetch_loop():
async for item in metadatafetch_queue:
title = await metadatafetch.title(item.url)
item.title = title
metadatafetch_queue.task_done()
class PlaylistItem:
def __init__(self, url):
self.url = url
self.title = None
class Playlist:
def __init__(self):
self.playlist = []
self.nonempty = nyasync.Condition(lambda: self.playlist)
self.change = nyasync.Event()
def queue(self, url):
item = PlaylistItem(url)
self.playlist.append(item)
metadatafetch_queue.put_nowait(item)
self.nonempty.notify()
self.change.notify()
async def dequeue(self):
await self.nonempty
self.change.notify()
return self.playlist.pop(0)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

View File

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800 800" style="enable-background:new 0 0 800 800;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:none;stroke:#FFFFFF;stroke-width:2;stroke-miterlimit:10;}
.st2{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
.st3{fill:none;}
.st4{stroke:#000000;stroke-miterlimit:10;}
.st5{font-family:'OCRAStd';}
.st6{font-size:126px;}
</style>
<g id="Layer_2">
<rect y="0" class="st0" width="800" height="800"/>
</g>
<g id="Layer_4">
<path class="st1" d="M294.6,720.3"/>
<line class="st1" x1="478.4" y1="720.3" x2="313.2" y2="720.3"/>
<path class="st1" d="M478.4,720.3"/>
<polyline class="st2" points="717.1,223.3 717.1,720.3 497.3,720.3 "/>
<path class="st2" d="M498.3,720.3c0-5.6-4.5-10.1-10.1-10.1c-5.6,0-10.1,4.5-10.1,10.1H314.3c0-5.6-4.5-10.1-10.1-10.1
c-5.6,0-10.1,4.5-10.1,10.1h0.6H76.5V79.7h640.5v120.8v-0.8h-17.3v24.8h17.3"/>
</g>
<g id="Layer_3">
<circle class="st2" cx="396.8" cy="400" r="320.3"/>
</g>
<g id="Layer_1">
<polyline class="st2" points="514.5,173.5 170.2,173.5 170.3,626.6 623.3,626.5 623.3,215.7 584.4,173.4 557,173.4 548,180.6
526.5,180.7 "/>
<path class="st1" d="M396.8,173.5"/>
<path class="st1" d="M396.8,173.3"/>
<path class="st2" d="M526.5,331.8c0,7.6-5.4,13.7-12,13.7H227.7c-6.6,0-12-6.1-12-13.7V187.2c0-7.6,5.4-13.7,12-13.7h286.8
c6.6,0,12,6.1,12,13.7V331.8z"/>
<path class="st2" d="M526.7,333.6c0,6.6-5.4,12-12,12H296.8c-6.6,0-12-5.4-12-12V185.5c0-6.6,5.4-12,12-12h217.9
c6.6,0,12,5.4,12,12V333.6z"/>
<path class="st2" d="M577.9,613.7c0,6.6-5.4,12-12,12H227.7c-6.6,0-12-5.4-12-12V381.1c0-6.6,5.4-12,12-12h338.2
c6.6,0,12,5.4,12,12V613.7z"/>
<rect x="179.9" y="590.2" class="st2" width="25.7" height="23"/>
<rect x="587.6" y="590.2" class="st2" width="25.7" height="23"/>
<rect x="433.6" y="193.5" class="st2" width="64.9" height="137.8"/>
</g>
<g id="Layer_5">
<rect x="258" y="442.5" class="st3" width="277.5" height="109.7"/>
<text transform="matrix(1 0 0 1 260.7021 547.998)" class="st4 st5 st6">PVV</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

22
mpv_control.py Normal file
View File

@ -0,0 +1,22 @@
from lib.python_mpv.mpv import MPV as next_MPV
import youtube_dl
ydl = youtube_dl.YoutubeDL()
class MPV(next_MPV):
def __init__(self):
self.default_argv += (
[ '--keep-open'
, '--force-window'
])
super().__init__(debug=True)
def play(self, url):
self.command("loadfile", path)
self.set_property("pause", False)
@staticmethod
def fetchTitle(url):
return ydl.extract_info(url, download=False).get('title')
mpv = MPV()

1639
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
[tool.poetry]
name = "grzegorz"
version = "0.2.0"
description = "A REST API for managing a MPV instance over via a RPC socket."
authors = ["Peder Bergebakken Sundt <pbsds@hotmail.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = ">=3.8,<4.0"
mpv = ">=0.1" # TODO: do we use this?
yt-dlp = ">=2023.9.24"
sanic = ">=23.12.0,<25"
sanic-ext = ">=23.12.0,<24"
#sanic-openapi = ">=21.6.1"
[tool.poetry.dev-dependencies]
python-lsp-server = {extras = ["all"], version = "^1.3.3"}
[tool.poetry.scripts]
grzegorz-mpv-command = 'grzegorz.mpv:print_mpv_command'
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
mpv==0.1
youtube-dl==2016.9.11.1

40
server.py Normal file
View File

@ -0,0 +1,40 @@
import remi.gui as gui
from remi import start, App
class MyApp(App):
def __init__(self, *args):
super(MyApp, self).__init__(*args)
self.pressed = 0
def main(self):
topContainer = gui.HBox()
container = gui.VBox(width=768)
topContainer.append(container)
#playback controls
#playlist
self.lbl = gui.Label('Hello world!')
self.bt = gui.Button('Press me!')
self.asdasd = gui.TextInput(hiehgt=30)
# setting the listener for the onclick event of the button
self.bt.set_on_click_listener(self, 'on_button_pressed')
# appending a widget to another, the first argument is a string key
container.append(self.lbl)
container.append(self.bt)
container.append(self.asdasd)
# returning the root widget
return container
# listener function
def on_button_pressed(self):
self.pressed += 1
self.lbl.set_text('Button pressed %i times!' % self.pressed)
# starts the webserver
start(MyApp, address="0.0.0.0", start_browser=False, multiple_instance=True)