diff --git a/.env.example b/.env.example index 38ff197..60b99ee 100644 --- a/.env.example +++ b/.env.example @@ -12,17 +12,10 @@ MEDIAPI_PASSWORD=changeme MEDIAPI_MEDIA_ROOTS=/localmedia MEDIAPI_PORT=8080 -# --- Kodi (the actual player) --- -# mediapi drives Kodi over its JSON-RPC HTTP endpoint. install.sh installs Kodi, -# starts it on KMS, and enables its web server with these credentials/port. -# Keep the port different from MEDIAPI_PORT above. -MEDIAPI_KODI_HOST=127.0.0.1 -MEDIAPI_KODI_PORT=8090 -MEDIAPI_KODI_USER=kodi -MEDIAPI_KODI_PASSWORD=changeme -# Kodi audio output. Default routes to the Pi's first HDMI port (Kodi otherwise -# picks the analog jack). Override for the 2nd HDMI port (vc4hdmi1) if needed. -#MEDIAPI_KODI_AUDIO_DEVICE=ALSA:hdmi:CARD=vc4hdmi0,DEV=0|vc4-hdmi-0 +# --- mpv (the actual player) --- +# mediapi drives mpv over its JSON IPC Unix socket. install.sh installs mpv and +# runs it as a service on KMS with --input-ipc-server pointed at this socket. +#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock # --- System: the Linux user the systemd services run as --- # Auto-detected from whoever runs install.sh (id -un) when left unset, so you diff --git a/install.sh b/install.sh index 4ade2e5..e39735f 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,7 @@ TARGET_REF="${1:-}" # script can proceed, but make it loud: the defaults ship an insecure AP. if [[ ! -f .env ]]; then echo "WARNING: .env not found -- creating it from .env.example." >&2 - echo " Edit .env with your real AP/login/Kodi passwords, then re-run." >&2 + echo " Edit .env with your real AP/login passwords, then re-run." >&2 cp .env.example .env fi set -a @@ -116,7 +116,7 @@ bootstrap_system() { # --- render + install systemd units (used again on rollback) -------- install_units() { - for unit in mediapi-kodi mediapi-app; do + for unit in mediapi-mpv mediapi-app; do sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \ -e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \ -e "s|\${UV}|${UV}|g" \ @@ -140,7 +140,7 @@ deploy_current() { chmod 600 instance/secret_key fi - # Kodi (the player) runs as MEDIAPI_USER on GBM/KMS and needs these groups to + # mpv (the player) runs as MEDIAPI_USER on KMS/DRM and needs these groups to # reach the GPU/DRM, audio, input and console devices. Idempotent; systemd # picks up the new membership when it (re)starts the service below. if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then @@ -148,10 +148,10 @@ deploy_current() { sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}" fi - # Install Kodi (the actual media player -- mediapi just drives it via JSON-RPC). - if ! command -v kodi-standalone >/dev/null 2>&1; then - echo "==> Installing Kodi ..." - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends kodi + # Install mpv (the actual media player -- mediapi drives it via its IPC socket). + if ! command -v mpv >/dev/null 2>&1; then + echo "==> Installing mpv ..." + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mpv fi # Ensure the media browse roots exist so browsing works and there's a place to @@ -184,36 +184,17 @@ deploy_current() { echo "==> Installing systemd units ..." install_units - sudo systemctl enable mediapi-kodi mediapi-app >/dev/null 2>&1 || true - - # Enable Kodi's JSON-RPC web server so mediapi can control it. Kodi rewrites - # guisettings.xml on exit, so seed it while Kodi is stopped, then start. - KODI_HOME="$(eval echo "~${MEDIAPI_USER}")/.kodi" - echo "==> Enabling Kodi web server (JSON-RPC) on port ${MEDIAPI_KODI_PORT:-8090} ..." - sudo systemctl stop mediapi-kodi 2>/dev/null || true - sudo -u "${MEDIAPI_USER}" mkdir -p "${KODI_HOME}/userdata" - sudo -u "${MEDIAPI_USER}" \ - MEDIAPI_KODI_PORT="${MEDIAPI_KODI_PORT:-8090}" \ - MEDIAPI_KODI_USER="${MEDIAPI_KODI_USER:-kodi}" \ - MEDIAPI_KODI_PASSWORD="${MEDIAPI_KODI_PASSWORD:-kodi}" \ - python3 "${PROJECT_DIR}/scripts/configure-kodi.py" "${KODI_HOME}/userdata/guisettings.xml" + sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true echo "==> Starting services ..." - sudo systemctl restart mediapi-kodi + sudo systemctl restart mediapi-mpv sudo systemctl restart mediapi-app - # Route Kodi's audio to HDMI. Kodi otherwise defaults to the Pi's analog jack - # (bcm2835 Headphones). Done live over JSON-RPC once Kodi's web server is up - # (more robust than seeding guisettings, since Kodi validates the device). - local kodi_url="http://${MEDIAPI_KODI_USER:-kodi}:${MEDIAPI_KODI_PASSWORD:-kodi}@127.0.0.1:${MEDIAPI_KODI_PORT:-8090}/jsonrpc" - local audio_dev="${MEDIAPI_KODI_AUDIO_DEVICE:-ALSA:hdmi:CARD=vc4hdmi0,DEV=0|vc4-hdmi-0}" - echo "==> Setting Kodi audio output to HDMI ..." + # Wait for mpv's IPC socket so a first-run deploy leaves a driveable player. + echo "==> Waiting for mpv IPC socket (${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}) ..." for _ in $(seq 1 30); do - if curl -fsS --max-time 3 "$kodi_url" -H 'content-type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' 2>/dev/null | grep -q pong; then - curl -fsS --max-time 5 "$kodi_url" -H 'content-type: application/json' \ - -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"Settings.SetSettingValue\",\"params\":{\"setting\":\"audiooutput.audiodevice\",\"value\":\"${audio_dev}\"}}" >/dev/null \ - && echo " audio -> ${audio_dev}" || echo " WARNING: could not set Kodi audio device" + if sudo test -S "${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"; then + echo " mpv is up." break fi sleep 1 @@ -240,7 +221,7 @@ echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..." if app_healthy; then echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE" echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}." - systemctl --no-pager --lines=0 status mediapi-kodi mediapi-app || true + systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app || true exit 0 fi diff --git a/mediapi/__init__.py b/mediapi/__init__.py index 4a595bc..65bc9ff 100644 --- a/mediapi/__init__.py +++ b/mediapi/__init__.py @@ -2,7 +2,7 @@ from flask import Flask from .auth import register_auth_gate from .config import Config -from .kodi import KodiClient +from .mpv import MpvClient from .player import PlayerStateManager from .routes.api_routes import bp as api_bp from .routes.auth_routes import bp as auth_bp @@ -19,12 +19,8 @@ def create_app(): register_auth_gate(app) - kodi = KodiClient( - app.config["KODI_URL"], - username=app.config["KODI_USER"], - password=app.config["KODI_PASSWORD"], - ) - app.player = PlayerStateManager(kodi, app.config["VIDEO_EXTENSIONS"]) + mpv = MpvClient(app.config["MPV_SOCKET"]) + app.player = PlayerStateManager(mpv, app.config["VIDEO_EXTENSIONS"]) app.player.start() return app diff --git a/mediapi/config.py b/mediapi/config.py index bcfbd04..adee158 100644 --- a/mediapi/config.py +++ b/mediapi/config.py @@ -75,14 +75,10 @@ class Config: MEDIA_ROOTS = [ p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p ] - # Kodi does the playback; we drive it over its JSON-RPC HTTP endpoint. - # Kodi's web server must be enabled (install.sh configures this) and should - # be on a different port than this app. Defaults match install.sh. - KODI_HOST = os.environ.get("MEDIAPI_KODI_HOST", "127.0.0.1") - KODI_PORT = int(os.environ.get("MEDIAPI_KODI_PORT", "8090")) - KODI_USER = os.environ.get("MEDIAPI_KODI_USER", "kodi") - KODI_PASSWORD = os.environ.get("MEDIAPI_KODI_PASSWORD", "kodi") - KODI_URL = f"http://{KODI_HOST}:{KODI_PORT}/jsonrpc" + # mpv does the playback; we drive it over its JSON IPC Unix socket. mpv runs + # as its own systemd service with --input-ipc-server pointed at this path + # (install.sh configures this). + MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock") PORT = int(os.environ.get("MEDIAPI_PORT", "8080")) VIDEO_EXTENSIONS = { diff --git a/mediapi/kodi.py b/mediapi/kodi.py deleted file mode 100644 index 0615db3..0000000 --- a/mediapi/kodi.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Minimal Kodi JSON-RPC client over HTTP. - -Kodi does the actual playback (hardware-decoded, straight on KMS, like -LibreELEC); mediapi is just the phone-facing UI that drives it. We talk to -Kodi's web server JSON-RPC endpoint (Settings > Services > Control > "Allow -remote control via HTTP"). Uses only the stdlib so the app keeps no extra deps. -""" - -import base64 -import json -import socket -import urllib.error -import urllib.request - - -class KodiError(Exception): - """Base class for all Kodi control errors.""" - - -class KodiConnectionError(KodiError): - """Couldn't reach Kodi's JSON-RPC endpoint (down, wrong port, auth).""" - - -class KodiCommandError(KodiError): - """Kodi received the request but returned a JSON-RPC error.""" - - -class KodiClient: - def __init__(self, url, username=None, password=None, timeout=4): - self.url = url - self.timeout = timeout - self._auth = None - if username: - token = base64.b64encode(f"{username}:{password or ''}".encode()).decode() - self._auth = "Basic " + token - self._id = 0 - - def call(self, method, **params): - """Invoke a JSON-RPC method and return its "result". Raises - KodiConnectionError if Kodi is unreachable, KodiCommandError if Kodi - rejects the request.""" - self._id += 1 - body = json.dumps( - {"jsonrpc": "2.0", "id": self._id, "method": method, "params": params} - ).encode("utf-8") - req = urllib.request.Request( - self.url, data=body, headers={"Content-Type": "application/json"} - ) - if self._auth: - req.add_header("Authorization", self._auth) - try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - msg = json.loads(resp.read().decode("utf-8")) - except (urllib.error.URLError, OSError, socket.timeout) as exc: - raise KodiConnectionError(str(exc)) from exc - except json.JSONDecodeError as exc: - raise KodiConnectionError(f"bad JSON from kodi: {exc}") from exc - if isinstance(msg, dict) and "error" in msg: - raise KodiCommandError(str(msg["error"])) - return msg.get("result") if isinstance(msg, dict) else None - - -def seconds_from_time(t): - """Kodi returns times as {hours, minutes, seconds, milliseconds}; flatten - to float seconds. Returns None for a missing/empty value.""" - if not isinstance(t, dict): - return None - return ( - t.get("hours", 0) * 3600 - + t.get("minutes", 0) * 60 - + t.get("seconds", 0) - + t.get("milliseconds", 0) / 1000.0 - ) diff --git a/mediapi/mpv.py b/mediapi/mpv.py new file mode 100644 index 0000000..21d2a79 --- /dev/null +++ b/mediapi/mpv.py @@ -0,0 +1,92 @@ +"""Minimal mpv JSON IPC client over a Unix socket. + +mpv does the actual playback (hardware-decoded, straight on KMS/DRM); mediapi is +just the phone-facing UI that drives it. mpv runs as its own systemd service +with `--idle --input-ipc-server=`, so it holds the playlist and keeps +playing on its own even if this app, the phone, or the WiFi drop out -- we only +send it commands and read its state. Uses only the stdlib so the app keeps no +extra deps. + +Protocol: connect to the Unix socket, write one `{"command": [...]}` JSON line, +and read newline-delimited JSON back. mpv also emits async `{"event": ...}` +lines on the same stream; we tag each request with a request_id and skip +anything that isn't the matching reply. A fresh connection per command keeps +this stateless (mpv accepts many concurrent IPC connections), mirroring how the +old Kodi client worked. +""" + +import json +import socket + + +class MpvError(Exception): + """Base class for all mpv control errors.""" + + +class MpvConnectionError(MpvError): + """Couldn't reach mpv's IPC socket (not running, wrong path, no perms).""" + + +class MpvCommandError(MpvError): + """mpv received the command but returned an error (e.g. bad property).""" + + +class MpvClient: + def __init__(self, socket_path, timeout=4): + self.socket_path = socket_path + self.timeout = timeout + self._id = 0 + + def command(self, *args): + """Send one mpv IPC command and return its `data` field. Raises + MpvConnectionError if mpv is unreachable, MpvCommandError if mpv rejects + the command.""" + self._id += 1 + req_id = self._id + payload = json.dumps({"command": list(args), "request_id": req_id}) + "\n" + + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(self.timeout) + sock.connect(self.socket_path) + sock.sendall(payload.encode("utf-8")) + reply = self._read_reply(sock, req_id) + except (OSError, socket.timeout) as exc: + raise MpvConnectionError(str(exc)) from exc + + if reply.get("error") != "success": + raise MpvCommandError(f"{args[0]}: {reply.get('error')}") + return reply.get("data") + + def _read_reply(self, sock, req_id): + """Read newline-delimited JSON from mpv until we see the reply whose + request_id matches ours, skipping interleaved async event lines.""" + with sock.makefile("r", encoding="utf-8") as stream: + for line in stream: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError as exc: + raise MpvConnectionError(f"bad JSON from mpv: {exc}") from exc + if msg.get("request_id") == req_id and "error" in msg: + return msg + raise MpvConnectionError("mpv closed the connection without a reply") + + def get_property(self, name): + """Return a property's value, or raise MpvCommandError if mpv can't + supply it (e.g. `time-pos` while idle -- 'property unavailable').""" + return self.command("get_property", name) + + def try_get(self, name, default=None): + """Like get_property but returns `default` when the property is simply + unavailable (idle player), so callers don't special-case idle state. + A real connection failure still propagates as MpvConnectionError.""" + try: + return self.command("get_property", name) + except MpvCommandError: + return default + + def set_property(self, name, value): + return self.command("set_property", name, value) diff --git a/mediapi/player.py b/mediapi/player.py index b5e2133..c61c6f3 100644 --- a/mediapi/player.py +++ b/mediapi/player.py @@ -3,32 +3,29 @@ import os import threading import time -from .kodi import KodiConnectionError, KodiError, seconds_from_time from .media import list_video_files +from .mpv import MpvConnectionError, MpvError log = logging.getLogger(__name__) POLL_INTERVAL = 1.0 RECONNECT_INTERVAL = 2.0 -# Kodi's video playlist id (Playlist.GetPlaylists: 0=audio, 1=video, 2=picture). -VIDEO_PLAYLIST_ID = 1 - class PlayerStateManager: - """Controls Kodi over JSON-RPC and caches its playback state. + """Controls mpv over its JSON IPC socket and caches its playback state. - Playback is driven through Kodi's own PLAYLIST: playing a file or folder - loads the whole folder into Kodi's video playlist and starts it. Kodi then - advances through the folder (and loops it, when "keep playing" is on) ALL BY - ITSELF -- so playback keeps going even if this app, the phone, or the WiFi - disconnect. mediapi only issues commands and polls state; it is never in the - playback loop. Next/Previous are Kodi playlist navigation; "keep playing" is - Kodi's repeat mode. + Playback is driven through mpv's own PLAYLIST: playing a file or folder + loads the whole folder into mpv's playlist and jumps to the chosen entry. + mpv then advances through the folder (and loops it, when "keep playing" is + on) ALL BY ITSELF -- so playback keeps going even if this app, the phone, or + the WiFi disconnect. mediapi only issues commands and polls state; it is + never in the playback loop. Next/Previous are mpv playlist navigation; "keep + playing" is mpv's `loop-playlist`. """ - def __init__(self, kodi_client, video_extensions): - self._kodi = kodi_client + def __init__(self, mpv_client, video_extensions): + self._mpv = mpv_client self.video_extensions = video_extensions self._lock = threading.Lock() @@ -55,65 +52,47 @@ class PlayerStateManager: def stop(self): self._stop.set() - # -- background loop (status only; Kodi owns auto-advance) ------------- + # -- background loop (status only; mpv owns auto-advance) -------------- def _run(self): while not self._stop.is_set(): try: self._poll_once() - except KodiConnectionError as exc: - log.debug("kodi not reachable: %s", exc) + except MpvConnectionError as exc: + log.debug("mpv not reachable: %s", exc) self._set_disconnected() time.sleep(RECONNECT_INTERVAL) continue - except KodiError as exc: - log.warning("kodi poll error: %s", exc) + except MpvError as exc: + log.warning("mpv poll error: %s", exc) time.sleep(POLL_INTERVAL) def _set_disconnected(self): with self._lock: self._state["connected"] = False - def _active_player(self): - """Return the active player dict ({playerid, type}) or None if idle.""" - players = self._kodi.call("Player.GetActivePlayers") - for p in players or []: - if p.get("type") in ("video", "audio"): - return p - return players[0] if players else None - - def _active_player_id(self): - player = self._active_player() - return player["playerid"] if player else None + def _has_media(self): + """True if mpv currently has a file loaded (not idle). Raises + MpvConnectionError if mpv is unreachable.""" + return self._mpv.try_get("path") is not None def _poll_once(self): - player = self._active_player() # raises KodiConnectionError if down + # `path` is unavailable while mpv sits idle; try_get returns None then. + # A genuine socket failure raises MpvConnectionError and marks us down. + path = self._mpv.try_get("path") # raises MpvConnectionError if down filename = position = duration = None paused = None - if player is not None: - pid = player["playerid"] - props = self._kodi.call( - "Player.GetProperties", - playerid=pid, - properties=["time", "totaltime", "speed"], - ) or {} - position = seconds_from_time(props.get("time")) - duration = seconds_from_time(props.get("totaltime")) - paused = props.get("speed", 1) == 0 + if path is not None: + filename = os.path.basename(path) or self._mpv.try_get("media-title") + position = self._mpv.try_get("time-pos") + duration = self._mpv.try_get("duration") + paused = bool(self._mpv.try_get("pause", False)) - item = self._kodi.call( - "Player.GetItem", playerid=pid, properties=["file"] - ) or {} - item = item.get("item", {}) - path = item.get("file") - filename = os.path.basename(path) if path else item.get("label") - - app = self._kodi.call( - "Application.GetProperties", properties=["volume"] - ) or {} - volume = app.get("volume") + volume = self._mpv.try_get("volume") + if volume is not None: + volume = int(round(volume)) with self._lock: self._state.update({ @@ -135,27 +114,21 @@ class PlayerStateManager: # -- helpers ----------------------------------------------------------- def _play_playlist(self, files, start_index): - """Load `files` into Kodi's video playlist and start at start_index. - Kodi advances through them on its own from here on.""" - self._kodi.call("Playlist.Clear", playlistid=VIDEO_PLAYLIST_ID) - for f in files: - self._kodi.call("Playlist.Add", playlistid=VIDEO_PLAYLIST_ID, item={"file": f}) - self._kodi.call( - "Player.Open", item={"playlistid": VIDEO_PLAYLIST_ID, "position": start_index} - ) + """Load `files` into mpv's playlist (folder order) and start at + start_index. mpv advances through them on its own from here on.""" + # `loadfile ... replace` starts a fresh playlist with the first file; + # append the rest to rebuild the folder in order, then jump to the + # chosen entry so the playlist matches the folder exactly. + self._mpv.command("loadfile", files[0], "replace") + for f in files[1:]: + self._mpv.command("loadfile", f, "append") + if start_index > 0: + self._mpv.set_property("playlist-pos", start_index) self._apply_repeat() def _apply_repeat(self): - """Set Kodi's repeat mode on the active player to match keep-playing. - Retries briefly: right after Player.Open the player may not be active - for a beat.""" - repeat = "all" if self._keep_playing else "off" - for _ in range(10): - pid = self._active_player_id() - if pid is not None: - self._kodi.call("Player.SetRepeat", playerid=pid, repeat=repeat) - return - time.sleep(0.2) + """Set mpv's playlist loop to match keep-playing.""" + self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no") # -- public command API (called from Flask routes) --------------------- @@ -178,46 +151,31 @@ class PlayerStateManager: self._play_playlist(files, 0) def playpause(self): - pid = self._active_player_id() - if pid is not None: - self._kodi.call("Player.PlayPause", playerid=pid) + if self._has_media(): + self._mpv.command("cycle", "pause") def next(self): - pid = self._active_player_id() - if pid is not None: - self._kodi.call("Player.GoTo", playerid=pid, to="next") + if self._has_media(): + self._mpv.command("playlist-next", "weak") def previous(self): - pid = self._active_player_id() - if pid is not None: - self._kodi.call("Player.GoTo", playerid=pid, to="previous") + if self._has_media(): + self._mpv.command("playlist-prev", "weak") def seek(self, offset_seconds): - pid = self._active_player_id() - if pid is not None: - # Kodi takes a relative jump as value={"seconds": N}. - self._kodi.call("Player.Seek", playerid=pid, value={"seconds": int(offset_seconds)}) + if self._has_media(): + self._mpv.command("seek", int(offset_seconds), "relative") def seek_to(self, position_seconds): """Seek to an absolute position (seconds from the start) -- used by the draggable progress bar.""" - pid = self._active_player_id() - if pid is not None: + if self._has_media(): pos = max(0, int(position_seconds)) - self._kodi.call( - "Player.Seek", - playerid=pid, - value={"time": { - "hours": pos // 3600, - "minutes": (pos % 3600) // 60, - "seconds": pos % 60, - "milliseconds": 0, - }}, - ) + self._mpv.command("seek", pos, "absolute") def set_volume(self, value): value = max(0, min(100, value)) - self._kodi.call("Application.SetVolume", volume=value) + self._mpv.set_property("volume", value) def set_keep_playing(self, enabled): with self._lock: diff --git a/mediapi/routes/api_routes.py b/mediapi/routes/api_routes.py index 7a36dd0..34d0048 100644 --- a/mediapi/routes/api_routes.py +++ b/mediapi/routes/api_routes.py @@ -1,6 +1,6 @@ from flask import Blueprint, current_app, jsonify, request -from ..kodi import KodiError +from ..mpv import MpvError from ..media import PathError, list_directory, resolve_path bp = Blueprint("api", __name__, url_prefix="/api") @@ -42,7 +42,7 @@ def play(): player.play_folder(resolved) except ValueError as exc: return jsonify({"error": str(exc)}), 400 - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -52,7 +52,7 @@ def play(): def playpause(): try: current_app.player.playpause() - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -61,7 +61,7 @@ def playpause(): def next_clip(): try: current_app.player.next() - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -70,7 +70,7 @@ def next_clip(): def previous_clip(): try: current_app.player.previous() - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -85,7 +85,7 @@ def seek(): try: current_app.player.seek(offset) - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -100,7 +100,7 @@ def seekto(): try: current_app.player.seek_to(position) - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -115,7 +115,7 @@ def volume(): try: current_app.player.set_volume(value) - except KodiError as exc: + except MpvError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) diff --git a/mediapi/static/js/app.js b/mediapi/static/js/app.js index 6d1b906..e326884 100644 --- a/mediapi/static/js/app.js +++ b/mediapi/static/js/app.js @@ -110,7 +110,7 @@ const slider = el("seek-slider"); slider.max = data.duration || 0; // Don't fight the user while they're dragging, or right after a seek - // (Kodi takes a beat to report the new position). + // (the player takes a beat to report the new position). if (!seeking && Date.now() > seekSuppressUntil) { slider.value = data.position || 0; el("time-pos").textContent = formatTime(data.position); diff --git a/scripts/configure-kodi.py b/scripts/configure-kodi.py deleted file mode 100644 index b1e7a4a..0000000 --- a/scripts/configure-kodi.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -"""Enable Kodi's JSON-RPC web server by seeding its guisettings.xml. - -mediapi controls Kodi over HTTP JSON-RPC, which requires "Allow remote control -via HTTP" -- a setting normally toggled in Kodi's GUI. This seeds it headlessly. - -IMPORTANT: Kodi rewrites guisettings.xml when it exits, so this must run while -Kodi is STOPPED (install.sh handles the stop/seed/start ordering). It merges -into any existing file, so re-running is safe. - -Reads the desired values from the environment (with sensible defaults matching -.env.example): - MEDIAPI_KODI_PORT, MEDIAPI_KODI_USER, MEDIAPI_KODI_PASSWORD - -Usage: configure-kodi.py [path/to/guisettings.xml] - (default: ~/.kodi/userdata/guisettings.xml) -""" - -import os -import sys -import xml.etree.ElementTree as ET - -DEFAULT_PATH = os.path.expanduser("~/.kodi/userdata/guisettings.xml") - -WANTED = { - "services.webserver": os.environ.get("MEDIAPI_KODI_WEBSERVER", "true"), - "services.webserverport": os.environ.get("MEDIAPI_KODI_PORT", "8090"), - "services.webserverusername": os.environ.get("MEDIAPI_KODI_USER", "kodi"), - "services.webserverpassword": os.environ.get("MEDIAPI_KODI_PASSWORD", "kodi"), - "services.webserverauthentication": "true", - "services.webserverssl": "false", -} - - -def main(): - path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH - os.makedirs(os.path.dirname(path), exist_ok=True) - - if os.path.exists(path): - tree = ET.parse(path) - root = tree.getroot() - else: - root = ET.Element("settings", {"version": "2"}) - tree = ET.ElementTree(root) - - existing = {el.get("id"): el for el in root.findall("setting")} - for setting_id, value in WANTED.items(): - el = existing.get(setting_id) - if el is None: - el = ET.SubElement(root, "setting", {"id": setting_id}) - el.text = value - - tree.write(path, encoding="utf-8", xml_declaration=True) - print(f"configure-kodi: web server enabled on port {WANTED['services.webserverport']} ({path})") - - -if __name__ == "__main__": - main() diff --git a/systemd/mediapi-app.service.template b/systemd/mediapi-app.service.template index 306bad0..9fac16b 100644 --- a/systemd/mediapi-app.service.template +++ b/systemd/mediapi-app.service.template @@ -1,7 +1,7 @@ [Unit] Description=MediaPi Flask app -After=network-online.target mediapi-kodi.service -Wants=network-online.target mediapi-kodi.service +After=network-online.target mediapi-mpv.service +Wants=network-online.target mediapi-mpv.service [Service] Type=simple diff --git a/systemd/mediapi-kodi.service.template b/systemd/mediapi-kodi.service.template deleted file mode 100644 index a94512f..0000000 --- a/systemd/mediapi-kodi.service.template +++ /dev/null @@ -1,25 +0,0 @@ -[Unit] -Description=Kodi media center (standalone, GBM/KMS -- the actual player) -# Needs the DRM/HDMI + sound devices present; takes over the console VT. -After=local-fs.target systemd-udev-settle.service sound.target -Wants=systemd-udev-settle.service -Conflicts=getty@tty1.service - -[Service] -User=${MEDIAPI_USER} -# Kodi on GBM needs: video+render (GPU/DRM), input (remotes/keyboard), audio -# (HDMI/ALSA), tty (own the VT). PAMName=login gives it a real session so it -# can take the seat's devices. -SupplementaryGroups=video render input audio tty dialout -PAMName=login -TTYPath=/dev/tty1 -StandardInput=tty -StandardOutput=journal -StandardError=journal -# kodi-standalone launches Kodi and relaunches it if it exits from the UI. -ExecStart=/usr/bin/kodi-standalone -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/systemd/mediapi-mpv.service.template b/systemd/mediapi-mpv.service.template new file mode 100644 index 0000000..8b1cdc5 --- /dev/null +++ b/systemd/mediapi-mpv.service.template @@ -0,0 +1,38 @@ +[Unit] +Description=mpv video player (KMS/DRM -- the actual player) +# Needs the DRM/HDMI + sound devices present; takes over the console VT. +After=local-fs.target systemd-udev-settle.service sound.target +Wants=systemd-udev-settle.service +Conflicts=getty@tty1.service + +[Service] +User=${MEDIAPI_USER} +# mpv on DRM/KMS needs: video+render (GPU/DRM), input (remotes/keyboard), audio +# (HDMI/ALSA), tty (own the VT). PAMName=login gives it a real logind session so +# it can take the seat's DRM/input devices. +SupplementaryGroups=video render input audio tty +PAMName=login +TTYPath=/dev/tty1 +StandardInput=tty +StandardOutput=journal +StandardError=journal +# Create /run/mediapi (owned by this user) for the IPC socket the app connects to. +RuntimeDirectory=mediapi +# --idle keeps mpv running with no file loaded (holds the playlist between +# clips and after the last one), so the service stays up and the app can always +# reach the socket. Playback is hardware-decoded straight on KMS. +ExecStart=/usr/bin/mpv \ + --idle=yes \ + --input-ipc-server=/run/mediapi/mpv.sock \ + --no-terminal \ + --no-input-default-bindings \ + --fullscreen \ + --force-window=yes \ + --keep-open=no \ + --vo=gpu --gpu-context=drm --hwdec=auto \ + --ao=alsa --audio-device=alsa/hdmi:CARD=vc4hdmi0,DEV=0 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target