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) <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-07-07 22:42:04 -04:00
parent ee72d8b735
commit afe1ab3e2f
14 changed files with 364 additions and 343 deletions

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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 = {

73
mediapi/kodi.py Normal file
View file

@ -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
)

View file

@ -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)

View file

@ -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:

View file

@ -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})

58
scripts/configure-kodi.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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