From afe1ab3e2fd0ab468314d12806f11c3fc829aa7f Mon Sep 17 00:00:00 2001 From: Jean-Michel Tremblay Date: Tue, 7 Jul 2026 22:42:04 -0400 Subject: [PATCH] Replace mpv with Kodi as the player; mediapi drives it over JSON-RPC Playback moves to Kodi (standalone on GBM/KMS -- the smooth, hardware-decoded LibreELEC path). mediapi becomes a thin remote: it browses media and controls Kodi over its JSON-RPC HTTP API. This drops every mpv/DRM/X-mirror problem (DRM-master exclusivity, gpu-next "export failed" wedges, X-mirror A/V desync, software-decode choppiness) -- none of which had a working single config. - new mediapi/kodi.py (stdlib JSON-RPC client); player.py rewritten to poll and drive Kodi (Player.Open/PlayPause/Seek, Application.SetVolume, Player.GetProperties), same public interface + keep-playing auto-advance - config.py: KODI_* settings replace MPV_SOCKET; __init__ + api_routes updated - delete mpv_ipc.py, the mpv/X units, start-mpv/session scripts - new systemd/mediapi-kodi.service.template (standalone Kodi on tty1) + scripts/configure-kodi.py (headlessly enable Kodi's JSON-RPC web server) - install.sh now bootstraps a BARE Pi OS Lite end to end: apt base packages, installs uv, installs Kodi, enables its web API, configures the AP, installs + starts services (only git need be preinstalled). Auto-seeds .env from the example on first run. - README + .env.example updated for the Kodi architecture Dual HDMI is handled in hardware (external splitter off one port); no software mirror -- see git history for why that can't work smoothly on this Pi. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 17 +-- README.md | 84 +++++++-------- install.sh | 81 ++++++++++----- mediapi/__init__.py | 9 +- mediapi/config.py | 11 +- mediapi/kodi.py | 73 +++++++++++++ mediapi/mpv_ipc.py | 85 --------------- mediapi/player.py | 142 +++++++++++++++----------- mediapi/routes/api_routes.py | 10 +- scripts/configure-kodi.py | 58 +++++++++++ scripts/mediapi-session.sh | 78 -------------- systemd/mediapi-app.service.template | 4 +- systemd/mediapi-kodi.service.template | 25 +++++ systemd/mediapi-mpv.service.template | 30 ------ 14 files changed, 364 insertions(+), 343 deletions(-) create mode 100644 mediapi/kodi.py delete mode 100644 mediapi/mpv_ipc.py create mode 100644 scripts/configure-kodi.py delete mode 100755 scripts/mediapi-session.sh create mode 100644 systemd/mediapi-kodi.service.template delete mode 100644 systemd/mediapi-mpv.service.template diff --git a/.env.example b/.env.example index a121e0c..daa24c9 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,15 @@ 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 + # --- System: the Linux user the systemd services run as --- # Auto-detected from whoever runs install.sh (id -un) when left unset, so you # normally don't need this. Uncomment only to force a specific user. @@ -23,11 +32,3 @@ MEDIAPI_AP_SSID=changeme MEDIAPI_AP_PASSWORD=changeme MEDIAPI_AP_CONN_NAME=mediapi-ap -# --- Advanced (leave unset unless you know you need them) --- -# Where the mpv IPC socket + runtime dir live. Defaults below are correct for -# the shipped systemd unit, which hardcodes RuntimeDirectory=mediapi (=/run/mediapi). -# If you override MEDIAPI_RUNTIME_DIR you MUST also edit -# systemd/mediapi-mpv.service.template's RuntimeDirectory to match, or the dir -# won't be created at boot. Most deployments should leave both commented out. -#MEDIAPI_RUNTIME_DIR=/run/mediapi -#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock diff --git a/README.md b/README.md index 7b0ed3c..060b08a 100644 --- a/README.md +++ b/README.md @@ -110,46 +110,45 @@ Keep values simple (no spaces / shell-special characters). ## Setup (mediapi app) -Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv` -player controlled over its JSON IPC socket. mpv renders video to HDMI; the -phone browser only shows metadata/controls, never the video image itself. +Playback is done by **Kodi** (`mediapi-kodi` unit), running standalone on +GBM/KMS straight on the hardware — the same smooth, hardware-decoded path as +LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is +just the phone-facing **remote**: it browses the media folders and drives Kodi +over its **JSON-RPC HTTP API** (`Player.Open`, `Player.PlayPause`, +`Player.Seek`, `Application.SetVolume`, `Player.GetProperties`). The phone +browser only shows metadata/controls, never the video image itself. -**Dual-HDMI mirroring.** The Pi 4's two HDMI connectors share a single vc4 DRM -card, and DRM *master* is exclusive per card — so two independent mpv processes -can't both drive a screen (the second gets `Failed to acquire DRM master`). -Instead the `mediapi-mpv` unit runs a minimal **X server** via `xinit` -(`scripts/mediapi-session.sh`): X is the one DRM master, `xrandr --same-as` -clones the first output's framebuffer onto every other connected HDMI output, -and a **single** fullscreen mpv renders into it — so the same frames appear on -both screens, decoded once. mpv carries audio and the app's only IPC socket -(`/run/mediapi/mpv.sock`); `--hwdec=v4l2m2m-copy` uses the Pi's hardware H.264/ -HEVC decoder (~⅓ the CPU of software decoding), falling back to software for -codecs it can't handle. The session waits for connectors to probe as connected -before mirroring, so a cold-boot HDMI race doesn't drop it to one screen. One -screen attached → just that screen, no config needed. +Kodi handles decode, HDMI audio and display; mediapi keeps a background poll of +Kodi's player state (position/duration/pause/volume) and drives "keep playing" +auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and +enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see +`scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env` +(`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`. + +> **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software +> (DRM master is exclusive per card; the X-mirror path can't keep up). Drive +> both car screens from one HDMI port through an external powered HDMI splitter. ### First-time install (on the Pi) ```bash -# system deps: mpv for playback (install.sh installs the minimal X server itself) -sudo apt update -sudo apt install -y mpv - # install uv (Python package/venv manager) if not already present curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv # create your .env (see Configuration above) cp .env.example .env && $EDITOR .env -# deploy the currently-checked-out ref: syncs deps, adds the service user to -# the video+render groups (GPU/DRM access for HDMI), configures the AP, -# installs+starts the services. Run as your normal user -- NOT with sudo, or -# the services get rendered to run as root. +# deploy the currently-checked-out ref: installs Kodi + Python deps, enables +# Kodi's JSON-RPC web server, configures the AP, installs+starts the services. +# Run as your normal user -- NOT with sudo, or the services get rendered to run +# as root. ./install.sh # verify -systemctl status mediapi-mpv mediapi-app -ls -l /run/mediapi/mpv.sock +systemctl status mediapi-kodi mediapi-app +curl -s "http://kodi:$(grep KODI_PASSWORD .env|cut -d= -f2)@127.0.0.1:8090/jsonrpc" \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' # -> {"result":"pong"} ``` Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to @@ -193,25 +192,20 @@ cd ~/mediapi && ./install.sh v1.2.0 sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay ``` -Notes / things to double check on the actual hardware (couldn't be verified -from a dev machine): +Notes / things to double check on the actual hardware: * `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt` - on current Bookworm Pi4 images (needed for DRM output) — worth a quick check. -* Mirroring clones every HDMI output that reads `connected` at session start - onto the first. The session waits for connectors to probe (cold-boot race), - then runs `xrandr --same-as`. See what it did with - `sudo journalctl -u mediapi-mpv | grep mediapi-session`; inspect the outputs - live with `DISPLAY=:0 xrandr` (as the service user). Both screens should share - a common resolution; if they differ, `xrandr` clones at the first output's - mode. Confirm the one socket: `ls -l /run/mediapi/mpv.sock`. -* Non-root X requires `/etc/X11/Xwrapper.config` with `allowed_users=anybody` - (install.sh writes it). If the service crash-loops with `Could not create - server lock file: /tmp/.X0-lock`, a previous X died uncleanly — remove - `/tmp/.X0-lock` (and `/tmp/.X11-unix/X0`) and restart. The X log is at - `~/.local/share/xorg/Xorg.0.log`. -* If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA - device name (usually `vc4-hdmi`) and add e.g. - `--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or - run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output. + on current Pi4 images (needed for KMS output) — worth a quick check. +* Kodi runs standalone on GBM as the `mediapi-kodi` service (on `tty1`). If the + screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's + own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the + `video render input audio tty` groups (install.sh adds them). +* If the web API is unreachable (`Connection refused` on + `:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites + `guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the + setting via `scripts/configure-kodi.py`, and restarts), or toggle + Settings → Services → Control → *Allow remote control via HTTP* in the Kodi + GUI once. Verify with `JSONRPC.Ping` (see install snippet above). +* Kodi handles HDMI audio itself (Settings → System → Audio). If there's no + sound, set the audio output device to the HDMI sink there. To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app` diff --git a/install.sh b/install.sh index 38d87df..f94ef5e 100755 --- a/install.sh +++ b/install.sh @@ -41,9 +41,12 @@ DEPLOYED_REF_FILE="instance/deployed_ref" TARGET_REF="${1:-}" # --- load config ---------------------------------------------------- +# First run on a fresh Pi may have no .env yet -- seed it from the example so the +# script can proceed, but make it loud: the defaults ship an insecure AP. if [[ ! -f .env ]]; then - echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2 - exit 1 + 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 + cp .env.example .env fi set -a # shellcheck disable=SC1091 @@ -52,7 +55,7 @@ set +a MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}" MEDIAPI_PORT="${MEDIAPI_PORT:-8080}" -UV="$(command -v uv || echo "$HOME/.local/bin/uv")" +UV="" # set by bootstrap_system once uv is installed # --- 0. refuse to run on a read-only overlay ------------------------ if findmnt -no FSTYPE / | grep -q overlay; then @@ -90,9 +93,30 @@ if [[ -n "$PREV_GOOD" && "$PREV_GOOD" != "$CURRENT_REF" ]]; then echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)" fi +# --- system bootstrap (fresh Pi: assume ONLY git is installed) ------- +# Installs everything else the deploy needs so a bare Raspberry Pi OS Lite goes +# from "git clone + ./install.sh" to a working player. NetworkManager (nmcli) +# and raspi-config already ship on Pi OS. Sets the global UV path afterwards. +bootstrap_system() { + echo "==> apt update + base packages (curl, python3) ..." + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + curl ca-certificates python3 + + if ! command -v uv >/dev/null 2>&1 && [[ ! -x "$HOME/.local/bin/uv" ]]; then + echo "==> Installing uv ..." + curl -LsSf https://astral.sh/uv/install.sh | sh + fi + UV="$(command -v uv || echo "$HOME/.local/bin/uv")" + if [[ ! -x "$UV" ]]; then + echo "ERROR: uv is still not available at '$UV' after install." >&2 + exit 1 + fi +} + # --- render + install systemd units (used again on rollback) -------- install_units() { - for unit in mediapi-mpv mediapi-app; do + for unit in mediapi-kodi mediapi-app; do sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \ -e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \ -e "s|\${UV}|${UV}|g" \ @@ -116,27 +140,19 @@ deploy_current() { chmod 600 instance/secret_key fi - # The mpv service runs as MEDIAPI_USER and needs the video+render groups to - # reach the GPU/DRM devices for HDMI output, plus tty+input to own the VT and - # read input under X. Idempotent; systemd picks up the new membership when it - # (re)starts the service below, so no logout needed. + # Kodi (the player) runs as MEDIAPI_USER on GBM/KMS 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 - echo "==> Adding '${MEDIAPI_USER}' to video,render,input,tty groups ..." - sudo usermod -aG video,render,input,tty "${MEDIAPI_USER}" + echo "==> Adding '${MEDIAPI_USER}' to video,render,input,audio,tty groups ..." + sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}" fi - # The player runs a minimal X server so a single mpv can be mirrored across - # both HDMI outputs (one DRM master; two separate mpv on the shared vc4 card - # cannot -- see scripts/mediapi-session.sh). Install just the X server + xinit - # + xrandr, and allow the non-root service user to start X on its VT. - if ! command -v Xorg >/dev/null 2>&1; then - echo "==> Installing minimal X server (xserver-xorg-core, xinit, xrandr) ..." - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - xserver-xorg-core xinit x11-xserver-utils + # 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 fi - echo "==> Configuring /etc/X11/Xwrapper.config (allow non-root X on the VT) ..." - printf 'allowed_users=anybody\nneeds_root_rights=yes\n' \ - | sudo tee /etc/X11/Xwrapper.config >/dev/null echo "==> Applying WiFi country + AP config ..." sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}" @@ -156,10 +172,24 @@ deploy_current() { ipv4.method shared fi - echo "==> Installing systemd units + restarting services ..." + echo "==> Installing systemd units ..." install_units - sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true - sudo systemctl restart mediapi-mpv + 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" + + echo "==> Starting services ..." + sudo systemctl restart mediapi-kodi sudo systemctl restart mediapi-app } @@ -175,6 +205,7 @@ app_healthy() { } # --- 2-6. deploy ---------------------------------------------------- +bootstrap_system deploy_current # --- 7. health check + rollback ------------------------------------ @@ -182,7 +213,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-mpv mediapi-app || true + systemctl --no-pager --lines=0 status mediapi-kodi mediapi-app || true exit 0 fi diff --git a/mediapi/__init__.py b/mediapi/__init__.py index d3b23be..4a595bc 100644 --- a/mediapi/__init__.py +++ b/mediapi/__init__.py @@ -2,6 +2,7 @@ from flask import Flask from .auth import register_auth_gate from .config import Config +from .kodi import KodiClient from .player import PlayerStateManager from .routes.api_routes import bp as api_bp from .routes.auth_routes import bp as auth_bp @@ -18,10 +19,12 @@ def create_app(): register_auth_gate(app) - app.player = PlayerStateManager( - app.config["MPV_SOCKET"], - app.config["VIDEO_EXTENSIONS"], + kodi = KodiClient( + app.config["KODI_URL"], + username=app.config["KODI_USER"], + password=app.config["KODI_PASSWORD"], ) + app.player = PlayerStateManager(kodi, app.config["VIDEO_EXTENSIONS"]) app.player.start() return app diff --git a/mediapi/config.py b/mediapi/config.py index 7464d82..bcfbd04 100644 --- a/mediapi/config.py +++ b/mediapi/config.py @@ -75,9 +75,14 @@ class Config: MEDIA_ROOTS = [ p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p ] - # A single mpv drives every HDMI output -- the X session mirrors the screens - # with xrandr (see scripts/mediapi-session.sh) -- so there is one socket. - MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock") + # 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" PORT = int(os.environ.get("MEDIAPI_PORT", "8080")) VIDEO_EXTENSIONS = { diff --git a/mediapi/kodi.py b/mediapi/kodi.py new file mode 100644 index 0000000..0615db3 --- /dev/null +++ b/mediapi/kodi.py @@ -0,0 +1,73 @@ +"""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_ipc.py b/mediapi/mpv_ipc.py deleted file mode 100644 index 0b542fe..0000000 --- a/mediapi/mpv_ipc.py +++ /dev/null @@ -1,85 +0,0 @@ -import json -import socket -import threading - - -class MpvIPCError(Exception): - """Base class for all mpv IPC errors.""" - - -class MpvConnectionError(MpvIPCError): - """The underlying socket is broken; caller should reconnect.""" - - -class MpvCommandError(MpvIPCError): - """mpv responded but rejected the command (e.g. a property that's - legitimately unavailable while idle, like time-pos with nothing loaded). - The connection itself is fine.""" - - -class MpvIPCClient: - """Minimal client for mpv's JSON IPC protocol over a unix domain socket. - - One request in flight at a time (guarded by a lock) since responses on - the socket aren't tagged with a request id in a way we bother matching -- - we just send a command and read the next response line. - """ - - def __init__(self, socket_path): - self.socket_path = socket_path - self._sock = None - self._file = None - self._lock = threading.Lock() - - def connect(self): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(2) - sock.connect(self.socket_path) - self._sock = sock - self._file = sock.makefile("rwb") - - def close(self): - if self._sock is not None: - try: - self._sock.close() - except OSError: - pass - self._sock = None - self._file = None - - @property - def connected(self): - return self._sock is not None - - def command(self, *args): - """Send an mpv command, return its "data" field. Raises MpvIPCError - on failure or if not connected -- caller (PlayerStateManager) is - responsible for reconnect logic.""" - if not self.connected: - raise MpvConnectionError("not connected") - - payload = json.dumps({"command": list(args)}) + "\n" - with self._lock: - try: - self._file.write(payload.encode("utf-8")) - self._file.flush() - while True: - line = self._file.readline() - if not line: - raise MpvConnectionError("socket closed") - msg = json.loads(line) - # skip async event notifications, wait for the command reply - if "event" in msg: - continue - if msg.get("error") != "success": - raise MpvCommandError(msg.get("error", "unknown error")) - return msg.get("data") - except (OSError, json.JSONDecodeError) as exc: - self.close() - raise MpvConnectionError(str(exc)) from exc - - def get_property(self, name): - return self.command("get_property", name) - - def set_property(self, name, value): - return self.command("set_property", name, value) diff --git a/mediapi/player.py b/mediapi/player.py index 11435f0..e2585c6 100644 --- a/mediapi/player.py +++ b/mediapi/player.py @@ -3,8 +3,8 @@ import os import threading import time +from .kodi import KodiClient, KodiConnectionError, KodiError, seconds_from_time from .media import list_video_files -from .mpv_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError log = logging.getLogger(__name__) @@ -13,18 +13,16 @@ RECONNECT_INTERVAL = 2.0 class PlayerStateManager: - """Owns the single connection to mpv's IPC socket. A background thread - polls mpv for playback state and drives "keep playing" auto-advance; - Flask request handlers only ever read the cached snapshot or send a - command through this class -- they never touch the socket directly.""" + """Owns control of Kodi. A background thread polls Kodi's JSON-RPC for + playback state and drives "keep playing" auto-advance; Flask request + handlers only ever read the cached snapshot or send a command through this + class. Kodi itself does the decoding/output -- mediapi is just the remote. + """ - def __init__(self, socket_path, video_extensions): - self.socket_path = socket_path + def __init__(self, kodi_client, video_extensions): + self._kodi = kodi_client self.video_extensions = video_extensions - # A single mpv drives every HDMI output (the X session mirrors the - # screens with xrandr -- see scripts/mediapi-session.sh), so there is - # exactly one socket to talk to. - self._client = MpvIPCClient(socket_path) + self._lock = threading.Lock() self._stop = threading.Event() @@ -38,10 +36,11 @@ class PlayerStateManager: "keep_playing": False, } + # Auto-advance queue (the containing folder), same model as before. self._queue_folder = None self._queue_files = [] self._queue_index = -1 - self._prev_idle = True + self._prev_playing = False def start(self): thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True) @@ -54,52 +53,63 @@ class PlayerStateManager: def _run(self): while not self._stop.is_set(): - if not self._client.connected: - try: - self._client.connect() - log.info("connected to mpv socket at %s", self.socket_path) - except OSError: - self._set_disconnected() - time.sleep(RECONNECT_INTERVAL) - continue - try: self._poll_once() - except MpvConnectionError as exc: - log.warning("lost connection to mpv: %s", exc) + except KodiConnectionError as exc: + log.debug("kodi not reachable: %s", exc) self._set_disconnected() time.sleep(RECONNECT_INTERVAL) continue - + except KodiError as exc: + log.warning("kodi poll error: %s", exc) time.sleep(POLL_INTERVAL) def _set_disconnected(self): with self._lock: self._state["connected"] = False - def _get_property_safe(self, name): - """Like client.get_property, but treats "property unavailable" - (normal for time-pos/duration/filename while mpv is idle) as None - instead of a fatal error -- only a real MpvConnectionError should - tear down the connection.""" - try: - return self._client.get_property(name) - except MpvCommandError: - return None + 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 _poll_once(self): - idle = bool(self._get_property_safe("idle-active")) - filename = self._get_property_safe("filename") - position = self._get_property_safe("time-pos") - duration = self._get_property_safe("duration") - paused = self._get_property_safe("pause") - volume = self._get_property_safe("volume") + player = self._active_player() # raises KodiConnectionError if down - if idle and not self._prev_idle: + filename = position = duration = None + paused = None + playing = player is not 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 + + 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") + + # Auto-advance: playback just ended (was playing, now nothing active). + if self._prev_playing and not playing: self._maybe_advance() - idle = self._get_property_safe("idle-active") - - self._prev_idle = bool(idle) + self._prev_playing = playing with self._lock: self._state.update({ @@ -112,7 +122,7 @@ class PlayerStateManager: }) def _maybe_advance(self): - """Called from the poll loop when mpv just went idle. If keep-playing + """Called from the poll loop when Kodi just went idle. If keep-playing is on and there's a next file in the current folder queue, load it.""" with self._lock: keep_playing = self._state["keep_playing"] @@ -128,31 +138,40 @@ class PlayerStateManager: if next_file: try: - self._client.command("loadfile", next_file, "replace") - except MpvIPCError as exc: - log.warning("auto-advance loadfile failed: %s", exc) + self._open(next_file) + except KodiError as exc: + log.warning("auto-advance failed: %s", exc) # -- public read API --------------------------------------------------- def get_status(self): with self._lock: - state = dict(self._state) - state["keep_playing"] = self._state["keep_playing"] - return state + return dict(self._state) + + # -- helpers ----------------------------------------------------------- + + def _open(self, path): + self._kodi.call("Player.Open", item={"file": path}) + # An Open means playback is starting, so treat the next idle as a real + # end-of-file (not the brief gap between stop and start). + self._prev_playing = True + + def _active_player_id(self): + player = self._active_player() + return player["playerid"] if player else None # -- public command API (called from Flask routes) --------------------- def play_file(self, path): - self._client.command("loadfile", path, "replace") - # Build the auto-advance queue from the containing folder, positioned - # at the file just started -- so "keep playing" continues through the - # rest of the folder even when you start from the middle. + self._open(path) + # Build the auto-advance queue from the containing folder, positioned at + # the file just started -- so "keep playing" continues through the rest + # of the folder even when you start from the middle. folder = os.path.dirname(path) files = list_video_files(folder, self.video_extensions) try: index = files.index(path) except ValueError: - # played file isn't in the folder listing (unusual) -- queue just it files = [path] index = 0 with self._lock: @@ -164,21 +183,26 @@ class PlayerStateManager: files = list_video_files(folder_path, self.video_extensions) if not files: raise ValueError(f"no video files in {folder_path}") - self._client.command("loadfile", files[0], "replace") + self._open(files[0]) with self._lock: self._queue_folder = folder_path self._queue_files = files self._queue_index = 0 def playpause(self): - self._client.command("cycle", "pause") + pid = self._active_player_id() + if pid is not None: + self._kodi.call("Player.PlayPause", playerid=pid) def seek(self, offset_seconds): - self._client.command("seek", offset_seconds, "relative") + 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)}) def set_volume(self, value): value = max(0, min(100, value)) - self._client.set_property("volume", value) + self._kodi.call("Application.SetVolume", 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 1590af3..73bae57 100644 --- a/mediapi/routes/api_routes.py +++ b/mediapi/routes/api_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint, current_app, jsonify, request +from ..kodi import KodiError from ..media import PathError, list_directory, resolve_path -from ..mpv_ipc import MpvIPCError 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 MpvIPCError as exc: + except KodiError 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 MpvIPCError as exc: + except KodiError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -67,7 +67,7 @@ def seek(): try: current_app.player.seek(offset) - except MpvIPCError as exc: + except KodiError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) @@ -82,7 +82,7 @@ def volume(): try: current_app.player.set_volume(value) - except MpvIPCError as exc: + except KodiError as exc: return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"ok": True}) diff --git a/scripts/configure-kodi.py b/scripts/configure-kodi.py new file mode 100644 index 0000000..b1e7a4a --- /dev/null +++ b/scripts/configure-kodi.py @@ -0,0 +1,58 @@ +#!/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/scripts/mediapi-session.sh b/scripts/mediapi-session.sh deleted file mode 100755 index 97284c1..0000000 --- a/scripts/mediapi-session.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/sh -# -# mediapi X session -- the X client launched by xinit from mediapi-mpv.service. -# -# Mirrors every extra HDMI output onto the first, then runs ONE fullscreen mpv. -# -# Why an X server at all (we used to run mpv straight on DRM/KMS for fast boot): -# DRM "master" is exclusive per card. The Pi 4's two HDMI connectors live on a -# single vc4 card, so two independent mpv processes cannot both modeset -- the -# second gets "Failed to acquire DRM master: Permission denied" and shows -# nothing. A single X server is the one DRM master and drives BOTH connectors; -# `xrandr --same-as` clones the first output's framebuffer onto the others, so -# one mpv appears on every screen. See git history for the full diagnosis. -# -set -u - -RUNTIME_DIR="${MEDIAPI_RUNTIME_DIR:-/run/mediapi}" -SOCKET="${MEDIAPI_MPV_SOCKET:-$RUNTIME_DIR/mpv.sock}" - -# Always-on car display: never blank or DPMS-off. -xset -dpms 2>/dev/null || true -xset s off 2>/dev/null || true - -connected() { xrandr --query 2>/dev/null | awk '/ connected/{print $1}'; } - -# Cold-boot race: the second HDMI connector may not be probed as "connected" -# when X starts. Wait for connected outputs to appear, then let the set settle -# so a screen that lights up a beat later is still mirrored (bounded so a -# genuinely single-screen setup doesn't hang the boot). -deadline=$(( $(date +%s) + 30 )) -outs="$(connected)" -while [ -z "$outs" ] && [ "$(date +%s)" -lt "$deadline" ]; do - sleep 1 - outs="$(connected)" -done - -stable=0 -settle_end=$(( $(date +%s) + 8 )) -while [ "$(date +%s)" -lt "$settle_end" ]; do - sleep 1 - cur="$(connected)" - if [ "$cur" != "$outs" ]; then - outs="$cur" - stable=0 - else - stable=$(( stable + 1 )) - [ "$stable" -ge 2 ] && break - fi -done - -primary="" -rest="" -for o in $outs; do - if [ -z "$primary" ]; then primary="$o"; else rest="$rest $o"; fi -done -echo "mediapi-session: connected outputs:$outs (primary=${primary:-none})" - -for o in $rest; do - echo "mediapi-session: mirroring $o onto $primary" - xrandr --output "$o" --same-as "$primary" || true -done - -# Single mpv, on X (so one DRM master drives every mirrored connector). Carries -# audio and the app's IPC socket; the app talks only to this one socket now. -# hwdec=v4l2m2m-copy uses the Pi's hardware H.264/HEVC decoder (falls back to -# software for codecs it can't handle). Roughly a third of the CPU of software -# decoding 1080p -- important for an always-on player in a warm car. The single -# mpv has the HW decoder to itself now, so there's no contention. -exec mpv \ - --idle=yes \ - --vo=gpu-next \ - --gpu-context=x11egl \ - --hwdec=v4l2m2m-copy \ - --fullscreen \ - --no-terminal \ - --keep-open=no \ - --input-ipc-server="$SOCKET" \ - --ao=alsa diff --git a/systemd/mediapi-app.service.template b/systemd/mediapi-app.service.template index 9fac16b..306bad0 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-mpv.service -Wants=network-online.target mediapi-mpv.service +After=network-online.target mediapi-kodi.service +Wants=network-online.target mediapi-kodi.service [Service] Type=simple diff --git a/systemd/mediapi-kodi.service.template b/systemd/mediapi-kodi.service.template new file mode 100644 index 0000000..a94512f --- /dev/null +++ b/systemd/mediapi-kodi.service.template @@ -0,0 +1,25 @@ +[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 deleted file mode 100644 index edaa41f..0000000 --- a/systemd/mediapi-mpv.service.template +++ /dev/null @@ -1,30 +0,0 @@ -[Unit] -Description=mediapi player (X kiosk: one mpv mirrored across all HDMI outputs) -# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist before -# X tries to grab them. Without this a cold-boot race can leave X unable to find -# a screen. (systemd-udev-settle is deprecated but still the simplest reliable -# gate here; the session script additionally waits for connectors to appear.) -After=local-fs.target systemd-udev-settle.service -Wants=systemd-udev-settle.service - -[Service] -Type=simple -User=${MEDIAPI_USER} -# video+render: GPU/DRM access. input: evdev under X. tty: own the VT for X. -SupplementaryGroups=video render input tty -# A controlling tty so the X server can take over VT 7. -TTYPath=/dev/tty7 -StandardInput=tty -StandardOutput=journal -StandardError=journal -RuntimeDirectory=mediapi -RuntimeDirectoryMode=0770 -# xinit starts the X server on VT 7 and runs the session script as its client. -# The session mirrors the HDMI outputs (single DRM master) and execs one mpv. -# Non-root X is permitted via /etc/X11/Xwrapper.config (installed by install.sh). -ExecStart=/usr/bin/xinit ${PROJECT_DIR}/scripts/mediapi-session.sh -- :0 vt7 -nolisten tcp -keeptty -Restart=on-failure -RestartSec=2 - -[Install] -WantedBy=multi-user.target