Compare commits

..

No commits in common. "master" and "0.0.0" have entirely different histories.

16 changed files with 475 additions and 521 deletions

View file

@ -12,15 +12,8 @@ MEDIAPI_PASSWORD=changeme
MEDIAPI_MEDIA_ROOTS=/localmedia MEDIAPI_MEDIA_ROOTS=/localmedia
MEDIAPI_PORT=8080 MEDIAPI_PORT=8080
# --- mpv (the actual player) --- # --- System: systemd services run as this Linux user ---
# mediapi drives mpv over its JSON IPC Unix socket. install.sh installs mpv and MEDIAPI_USER=pi
# 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
# normally don't need this. Uncomment only to force a specific user.
#MEDIAPI_USER=pi
# --- WiFi access point (re-applied by install.sh) --- # --- WiFi access point (re-applied by install.sh) ---
MEDIAPI_WIFI_COUNTRY=US MEDIAPI_WIFI_COUNTRY=US
@ -28,3 +21,11 @@ MEDIAPI_AP_SSID=changeme
MEDIAPI_AP_PASSWORD=changeme MEDIAPI_AP_PASSWORD=changeme
MEDIAPI_AP_CONN_NAME=mediapi-ap 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,45 +110,48 @@ Keep values simple (no spaces / shell-special characters).
## Setup (mediapi app) ## Setup (mediapi app)
Playback is done by **Kodi** (`mediapi-kodi` unit), running standalone on Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
GBM/KMS straight on the hardware — the same smooth, hardware-decoded path as player controlled over its JSON IPC socket. mpv renders video to HDMI
LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is directly (DRM/KMS, no desktop needed); the phone browser only shows
just the phone-facing **remote**: it browses the media folders and drives Kodi metadata/controls, never the video image itself.
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.
Kodi handles decode, HDMI audio and display; mediapi keeps a background poll of **Dual-HDMI mirroring.** A single mpv on DRM can only drive one connector, and
Kodi's player state (position/duration/pause/volume) and drives "keep playing" the Pi's `vc4-kms` driver can't clone two HDMI outputs onto one framebuffer —
auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and so mirroring is done by running *one mpv per connected screen*.
enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see `scripts/start-mpv.py` (launched by the `mediapi-mpv` unit) enumerates the
`scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env` connected HDMI connectors and starts a **primary** mpv (audio + the app's IPC
(`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`. socket, `mpv.sock`) plus a **mirror** mpv per extra screen (video-only, on
`mpv-mirror-<connector>.sock`). The app plays/pauses/seeks the primary and
> **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software echoes those to the mirrors best-effort, re-syncing on every video. One screen
> (DRM master is exclusive per card; the X-mirror path can't keep up). Drive → same as before; a dead mirror just leaves that screen dark, never the audio
> both car screens from one HDMI port through an external powered HDMI splitter. screen. Because each screen runs its own mpv, the same file decodes once per
screen (`--hwdec=auto-safe` falls back to software if the HW decoder is busy).
### First-time install (on the Pi) ### First-time install (on the Pi)
```bash ```bash
# system deps: mpv for HDMI/DRM playback
sudo apt update
sudo apt install -y mpv
# mpv needs access to the GPU/DRM devices to render to HDMI
# (use the MEDIAPI_USER from your .env)
sudo usermod -aG video,render "$USER"
# log out/in (or reboot) for the new group membership to take effect
# install uv (Python package/venv manager) if not already present # install uv (Python package/venv manager) if not already present
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
# create your .env (see Configuration above) # create your .env (see Configuration above)
cp .env.example .env && $EDITOR .env cp .env.example .env && $EDITOR .env
# deploy the currently-checked-out ref: installs Kodi + Python deps, enables # deploy the currently-checked-out ref: syncs deps, configures the AP,
# Kodi's JSON-RPC web server, configures the AP, installs+starts the services. # installs+starts the services
# Run as your normal user -- NOT with sudo, or the services get rendered to run
# as root.
./install.sh ./install.sh
# verify # verify
systemctl status mediapi-kodi mediapi-app systemctl status mediapi-mpv mediapi-app
curl -s "http://kodi:$(grep KODI_PASSWORD .env|cut -d= -f2)@127.0.0.1:8090/jsonrpc" \ ls -l /run/mediapi/mpv.sock
-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 Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
@ -192,20 +195,20 @@ cd ~/mediapi && ./install.sh v1.2.0
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
``` ```
Notes / things to double check on the actual hardware: Notes / things to double check on the actual hardware (couldn't be verified
from a dev machine):
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt` * `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
on current Pi4 images (needed for KMS output) — worth a quick check. on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
* Kodi runs standalone on GBM as the `mediapi-kodi` service (on `tty1`). If the * Mirroring picks up whatever HDMI connectors read `connected` under
screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's `/sys/class/drm/card*-HDMI-*/status` at service start — so plug in both
own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the screens **before** `mediapi-mpv` starts (or `sudo systemctl restart
`video render input audio tty` groups (install.sh adds them). mediapi-mpv` after). Check what it launched with
* If the web API is unreachable (`Connection refused` on `sudo journalctl -u mediapi-mpv | grep start-mpv` and confirm both sockets:
`:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites `ls -l /run/mediapi/mpv*.sock`. `mpv --drm-connector=help` (with a display
`guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the attached) lists the connector names if the sysfs guess is ever wrong.
setting via `scripts/configure-kodi.py`, and restarts), or toggle * If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA
Settings → Services → Control → *Allow remote control via HTTP* in the Kodi device name (usually `vc4-hdmi`) and add e.g.
GUI once. Verify with `JSONRPC.Ping` (see install snippet above). `--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
* Kodi handles HDMI audio itself (Settings → System → Audio). If there's no run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
sound, set the audio output device to the HDMI sink there.
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app` To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`

View file

@ -41,12 +41,9 @@ DEPLOYED_REF_FILE="instance/deployed_ref"
TARGET_REF="${1:-}" TARGET_REF="${1:-}"
# --- load config ---------------------------------------------------- # --- 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 if [[ ! -f .env ]]; then
echo "WARNING: .env not found -- creating it from .env.example." >&2 echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2
echo " Edit .env with your real AP/login passwords, then re-run." >&2 exit 1
cp .env.example .env
fi fi
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@ -55,7 +52,7 @@ set +a
MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}" MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}"
MEDIAPI_PORT="${MEDIAPI_PORT:-8080}" MEDIAPI_PORT="${MEDIAPI_PORT:-8080}"
UV="" # set by bootstrap_system once uv is installed UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
# --- 0. refuse to run on a read-only overlay ------------------------ # --- 0. refuse to run on a read-only overlay ------------------------
if findmnt -no FSTYPE / | grep -q overlay; then if findmnt -no FSTYPE / | grep -q overlay; then
@ -93,30 +90,9 @@ if [[ -n "$PREV_GOOD" && "$PREV_GOOD" != "$CURRENT_REF" ]]; then
echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)" echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)"
fi 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) -------- # --- render + install systemd units (used again on rollback) --------
install_units() { install_units() {
for unit in mediapi-mpv mediapi-app mediapi-watchdog; do for unit in mediapi-mpv mediapi-app; do
sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \ sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \
-e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \ -e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \
-e "s|\${UV}|${UV}|g" \ -e "s|\${UV}|${UV}|g" \
@ -140,30 +116,6 @@ deploy_current() {
chmod 600 instance/secret_key chmod 600 instance/secret_key
fi fi
# 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
echo "==> Adding '${MEDIAPI_USER}' to video,render,input,audio,tty groups ..."
sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}"
fi
# 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
# copy content into. (mkdir -p is a no-op if it's already a mount point.)
IFS=':' read -ra _media_roots <<< "${MEDIAPI_MEDIA_ROOTS:-/localmedia}"
for r in "${_media_roots[@]}"; do
[[ -n "$r" && ! -d "$r" ]] || continue
echo "==> Creating media root $r ..."
sudo mkdir -p "$r"
sudo chown "${MEDIAPI_USER}:${MEDIAPI_USER}" "$r"
done
echo "==> Applying WiFi country + AP config ..." echo "==> Applying WiFi country + AP config ..."
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}" sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then
@ -182,38 +134,11 @@ deploy_current() {
ipv4.method shared ipv4.method shared
fi fi
echo "==> Installing systemd units ..." echo "==> Installing systemd units + restarting services ..."
install_units install_units
sudo systemctl enable mediapi-mpv mediapi-app mediapi-watchdog >/dev/null 2>&1 || true sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true
# Migration cleanup: earlier versions ran Kodi as the player. A still-running
# Kodi keeps the GPU's DRM master and the tty1 seat, which stops mpv from ever
# acquiring the display -- and removing a unit file does NOT stop an already
# running process. So explicitly tear any Kodi down before starting mpv.
if [[ -e /etc/systemd/system/mediapi-kodi.service ]] || pgrep -x kodi.bin >/dev/null 2>&1; then
echo "==> Removing leftover Kodi player ..."
sudo systemctl unmask mediapi-kodi.service 2>/dev/null || true
sudo systemctl disable --now mediapi-kodi.service 2>/dev/null || true
sudo rm -f /etc/systemd/system/mediapi-kodi.service
sudo pkill -9 -x kodi.bin 2>/dev/null || true
sudo pkill -9 -f kodi-standalone 2>/dev/null || true
sudo systemctl daemon-reload
fi
echo "==> Starting services ..."
sudo systemctl restart mediapi-mpv sudo systemctl restart mediapi-mpv
sudo systemctl restart mediapi-app sudo systemctl restart mediapi-app
sudo systemctl restart mediapi-watchdog
# 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 sudo test -S "${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"; then
echo " mpv is up."
break
fi
sleep 1
done
} }
# --- health check --------------------------------------------------- # --- health check ---------------------------------------------------
@ -228,7 +153,6 @@ app_healthy() {
} }
# --- 2-6. deploy ---------------------------------------------------- # --- 2-6. deploy ----------------------------------------------------
bootstrap_system
deploy_current deploy_current
# --- 7. health check + rollback ------------------------------------ # --- 7. health check + rollback ------------------------------------
@ -236,7 +160,7 @@ echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..."
if app_healthy; then if app_healthy; then
echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE" echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE"
echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}." echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}."
systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app mediapi-watchdog || true systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app || true
exit 0 exit 0
fi fi

View file

@ -2,7 +2,6 @@ from flask import Flask
from .auth import register_auth_gate from .auth import register_auth_gate
from .config import Config from .config import Config
from .mpv import MpvClient
from .player import PlayerStateManager from .player import PlayerStateManager
from .routes.api_routes import bp as api_bp from .routes.api_routes import bp as api_bp
from .routes.auth_routes import bp as auth_bp from .routes.auth_routes import bp as auth_bp
@ -19,8 +18,11 @@ def create_app():
register_auth_gate(app) register_auth_gate(app)
mpv = MpvClient(app.config["MPV_SOCKET"]) app.player = PlayerStateManager(
app.player = PlayerStateManager(mpv, app.config["VIDEO_EXTENSIONS"]) app.config["MPV_SOCKET"],
app.config["VIDEO_EXTENSIONS"],
mirror_glob=app.config.get("MPV_MIRROR_GLOB"),
)
app.player.start() app.player.start()
return app return app

View file

@ -75,10 +75,11 @@ class Config:
MEDIA_ROOTS = [ MEDIA_ROOTS = [
p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p
] ]
# 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") MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
# Extra mpv instances (one per additional HDMI screen) expose sockets named
# mpv-mirror-<connector>.sock alongside the primary socket. The player
# discovers them by glob and echoes playback commands to them best-effort.
MPV_MIRROR_GLOB = os.path.join(os.path.dirname(MPV_SOCKET), "mpv-mirror-*.sock")
PORT = int(os.environ.get("MEDIAPI_PORT", "8080")) PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

View file

@ -1,92 +0,0 @@
"""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=<socket>`, 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)

85
mediapi/mpv_ipc.py Normal file
View file

@ -0,0 +1,85 @@
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

@ -1,10 +1,11 @@
import glob
import logging import logging
import os import os
import threading import threading
import time import time
from .media import list_video_files from .media import list_video_files
from .mpv import MpvConnectionError, MpvError from .mpv_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -13,28 +14,26 @@ RECONNECT_INTERVAL = 2.0
class PlayerStateManager: class PlayerStateManager:
"""Controls mpv over its JSON IPC socket and caches its playback state. """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."""
Playback is driven through mpv's own PLAYLIST: playing a file or folder def __init__(self, socket_path, video_extensions, mirror_glob=None):
loads the whole folder into mpv's playlist and jumps to the chosen entry. self.socket_path = socket_path
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, mpv_client, video_extensions):
self._mpv = mpv_client
self.video_extensions = video_extensions self.video_extensions = video_extensions
# Sockets of any per-screen "mirror" mpv instances (see start-mpv.py).
# We only ever push playback commands to these best-effort; the primary
# socket above is the sole source of state and the one that carries
# audio, so a missing/broken mirror just means one screen is dark.
self._mirror_glob = mirror_glob
self._mirror_clients = {}
self._mirror_lock = threading.Lock()
self._client = MpvIPCClient(socket_path)
self._lock = threading.Lock() self._lock = threading.Lock()
self._stop = threading.Event() self._stop = threading.Event()
# Desired repeat state. Default ON: a lean-back player (kids' videos in a
# car) should keep running through/looping the folder, not stop.
self._keep_playing = True
self._state = { self._state = {
"connected": False, "connected": False,
"filename": None, "filename": None,
@ -42,9 +41,14 @@ class PlayerStateManager:
"duration": None, "duration": None,
"paused": None, "paused": None,
"volume": None, "volume": None,
"keep_playing": True, "keep_playing": False,
} }
self._queue_folder = None
self._queue_files = []
self._queue_index = -1
self._prev_idle = True
def start(self): def start(self):
thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True) thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True)
thread.start() thread.start()
@ -52,47 +56,56 @@ class PlayerStateManager:
def stop(self): def stop(self):
self._stop.set() self._stop.set()
# -- background loop (status only; mpv owns auto-advance) -------------- # -- background loop -------------------------------------------------
def _run(self): def _run(self):
while not self._stop.is_set(): while not self._stop.is_set():
if not self._client.connected:
try: try:
self._poll_once() self._client.connect()
except MpvConnectionError as exc: log.info("connected to mpv socket at %s", self.socket_path)
log.debug("mpv not reachable: %s", exc) except OSError:
self._set_disconnected() self._set_disconnected()
time.sleep(RECONNECT_INTERVAL) time.sleep(RECONNECT_INTERVAL)
continue continue
except MpvError as exc:
log.warning("mpv poll error: %s", exc) try:
self._poll_once()
except MpvConnectionError as exc:
log.warning("lost connection to mpv: %s", exc)
self._set_disconnected()
time.sleep(RECONNECT_INTERVAL)
continue
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
def _set_disconnected(self): def _set_disconnected(self):
with self._lock: with self._lock:
self._state["connected"] = False self._state["connected"] = False
def _has_media(self): def _get_property_safe(self, name):
"""True if mpv currently has a file loaded (not idle). Raises """Like client.get_property, but treats "property unavailable"
MpvConnectionError if mpv is unreachable.""" (normal for time-pos/duration/filename while mpv is idle) as None
return self._mpv.try_get("path") is not 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 _poll_once(self): def _poll_once(self):
# `path` is unavailable while mpv sits idle; try_get returns None then. idle = bool(self._get_property_safe("idle-active"))
# A genuine socket failure raises MpvConnectionError and marks us down. filename = self._get_property_safe("filename")
path = self._mpv.try_get("path") # raises MpvConnectionError if down 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")
filename = position = duration = None if idle and not self._prev_idle:
paused = None self._maybe_advance()
idle = self._get_property_safe("idle-active")
if path is not None: self._prev_idle = bool(idle)
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))
volume = self._mpv.try_get("volume")
if volume is not None:
volume = int(round(volume))
with self._lock: with self._lock:
self._state.update({ self._state.update({
@ -102,83 +115,108 @@ class PlayerStateManager:
"duration": duration, "duration": duration,
"paused": paused, "paused": paused,
"volume": volume, "volume": volume,
"keep_playing": self._keep_playing,
}) })
def _maybe_advance(self):
"""Called from the poll loop when mpv 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"]
has_next = (
self._queue_files
and 0 <= self._queue_index + 1 < len(self._queue_files)
)
if keep_playing and has_next:
self._queue_index += 1
next_file = self._queue_files[self._queue_index]
else:
next_file = None
if next_file:
try:
self._client.command("loadfile", next_file, "replace")
except MpvIPCError as exc:
log.warning("auto-advance loadfile failed: %s", exc)
self._broadcast("loadfile", next_file, "replace")
# -- public read API --------------------------------------------------- # -- public read API ---------------------------------------------------
def get_status(self): def get_status(self):
with self._lock: with self._lock:
return dict(self._state) state = dict(self._state)
state["keep_playing"] = self._state["keep_playing"]
return state
# -- helpers ----------------------------------------------------------- # -- mirror screens (best-effort) --------------------------------------
def _play_playlist(self, files, start_index): def _broadcast(self, *command_args):
"""Load `files` into mpv's playlist (folder order) and start at """Echo a playback command to every mirror mpv, best-effort. Never
start_index. mpv advances through them on its own from here on.""" raises: a mirror that's absent, mid-restart, or wedged must not affect
# `loadfile ... replace` starts a fresh playlist with the first file; the primary screen or the HTTP response. Mirrors are re-synced on every
# append the rest to rebuild the folder in order, then jump to the loadfile, so a command missed here self-heals at the next video."""
# chosen entry so the playlist matches the folder exactly. if not self._mirror_glob:
self._mpv.command("loadfile", files[0], "replace") return
for f in files[1:]: with self._mirror_lock:
self._mpv.command("loadfile", f, "append") paths = set(glob.glob(self._mirror_glob))
if start_index > 0: # Drop clients whose socket disappeared (display unplugged).
self._mpv.set_property("playlist-pos", start_index) for gone in set(self._mirror_clients) - paths:
self._apply_repeat() self._mirror_clients.pop(gone).close()
for path in paths:
def _apply_repeat(self): client = self._mirror_clients.get(path)
"""Set mpv's playlist loop to match keep-playing.""" if client is None:
self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no") client = self._mirror_clients[path] = MpvIPCClient(path)
try:
if not client.connected:
client.connect()
client.command(*command_args)
except (OSError, MpvIPCError) as exc:
client.close()
log.debug("mirror %s command failed: %s", path, exc)
# -- public command API (called from Flask routes) --------------------- # -- public command API (called from Flask routes) ---------------------
def play_file(self, path): def play_file(self, path):
# Queue the whole containing folder so playback continues through the self._client.command("loadfile", path, "replace")
# rest of it, starting at the chosen file. self._broadcast("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.
folder = os.path.dirname(path) folder = os.path.dirname(path)
files = list_video_files(folder, self.video_extensions) files = list_video_files(folder, self.video_extensions)
try: try:
index = files.index(path) index = files.index(path)
except ValueError: except ValueError:
# played file isn't in the folder listing (unusual) -- queue just it
files = [path] files = [path]
index = 0 index = 0
self._play_playlist(files, index) with self._lock:
self._queue_folder = folder
self._queue_files = files
self._queue_index = index
def play_folder(self, folder_path): def play_folder(self, folder_path):
files = list_video_files(folder_path, self.video_extensions) files = list_video_files(folder_path, self.video_extensions)
if not files: if not files:
raise ValueError(f"no video files in {folder_path}") raise ValueError(f"no video files in {folder_path}")
self._play_playlist(files, 0) self._client.command("loadfile", files[0], "replace")
self._broadcast("loadfile", files[0], "replace")
with self._lock:
self._queue_folder = folder_path
self._queue_files = files
self._queue_index = 0
def playpause(self): def playpause(self):
if self._has_media(): self._client.command("cycle", "pause")
self._mpv.command("cycle", "pause") self._broadcast("cycle", "pause")
def next(self):
if self._has_media():
self._mpv.command("playlist-next", "weak")
def previous(self):
if self._has_media():
self._mpv.command("playlist-prev", "weak")
def seek(self, offset_seconds): def seek(self, offset_seconds):
if self._has_media(): self._client.command("seek", offset_seconds, "relative")
self._mpv.command("seek", int(offset_seconds), "relative") self._broadcast("seek", offset_seconds, "relative")
def seek_to(self, position_seconds):
"""Seek to an absolute position (seconds from the start) -- used by the
draggable progress bar."""
if self._has_media():
pos = max(0, int(position_seconds))
self._mpv.command("seek", pos, "absolute")
def set_volume(self, value): def set_volume(self, value):
value = max(0, min(100, value)) value = max(0, min(100, value))
self._mpv.set_property("volume", value) self._client.set_property("volume", value)
def set_keep_playing(self, enabled): def set_keep_playing(self, enabled):
with self._lock: with self._lock:
self._keep_playing = bool(enabled) self._state["keep_playing"] = bool(enabled)
self._state["keep_playing"] = self._keep_playing
self._apply_repeat()

View file

@ -1,7 +1,7 @@
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from ..mpv import MpvError
from ..media import PathError, list_directory, resolve_path from ..media import PathError, list_directory, resolve_path
from ..mpv_ipc import MpvIPCError
bp = Blueprint("api", __name__, url_prefix="/api") bp = Blueprint("api", __name__, url_prefix="/api")
@ -42,7 +42,7 @@ def play():
player.play_folder(resolved) player.play_folder(resolved)
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except MpvError as exc: except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True}) return jsonify({"ok": True})
@ -52,25 +52,7 @@ def play():
def playpause(): def playpause():
try: try:
current_app.player.playpause() current_app.player.playpause()
except MpvError as exc: except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/next", methods=["POST"])
def next_clip():
try:
current_app.player.next()
except MpvError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/previous", methods=["POST"])
def previous_clip():
try:
current_app.player.previous()
except MpvError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True}) return jsonify({"ok": True})
@ -85,22 +67,7 @@ def seek():
try: try:
current_app.player.seek(offset) current_app.player.seek(offset)
except MpvError as exc: except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/seekto", methods=["POST"])
def seekto():
body = request.get_json(force=True, silent=True) or {}
try:
position = float(body.get("position"))
except (TypeError, ValueError):
return jsonify({"error": "expected {position: seconds}"}), 400
try:
current_app.player.seek_to(position)
except MpvError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True}) return jsonify({"ok": True})
@ -115,7 +82,7 @@ def volume():
try: try:
current_app.player.set_volume(value) current_app.player.set_volume(value)
except MpvError as exc: except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True}) return jsonify({"ok": True})

View file

@ -135,26 +135,18 @@ html, body {
.transport-row { .transport-row {
display: flex; display: flex;
gap: 0.4rem; gap: 0.5rem;
margin: 1rem 0; margin: 1rem 0;
} }
.transport-row button { .transport-row button {
flex: 1; flex: 1;
padding: 0.8rem 0.3rem; padding: 0.8rem;
border-radius: 6px; border-radius: 6px;
border: 1px solid #444; border: 1px solid #444;
background: #1c1c1c; background: #1c1c1c;
color: #eee; color: #eee;
font-size: 1rem; font-size: 1rem;
white-space: nowrap;
}
/* Prev/Next are compact icon buttons; the middle three carry the labels. */
#btn-prev, #btn-next {
flex: 0 0 auto;
min-width: 2.6rem;
font-size: 1.2rem;
} }
.volume-row { .volume-row {

View file

@ -3,9 +3,6 @@
let currentPath = null; // null = top-level media roots let currentPath = null; // null = top-level media roots
let volumeDebounce = null; let volumeDebounce = null;
let seeking = false; // user is dragging the progress bar
let seekSuppressUntil = 0; // ignore poll updates briefly after a seek
let keepSuppressUntil = 0; // ignore poll updates briefly after toggling keep-playing
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@ -105,23 +102,17 @@
if (!online) return; if (!online) return;
el("now-playing").textContent = data.filename || "—"; el("now-playing").textContent = data.filename || "—";
el("time-pos").textContent = formatTime(data.position);
el("time-dur").textContent = formatTime(data.duration); el("time-dur").textContent = formatTime(data.duration);
const slider = el("seek-slider"); const slider = el("seek-slider");
slider.max = data.duration || 0; slider.max = data.duration || 0;
// Don't fight the user while they're dragging, or right after a seek
// (the player takes a beat to report the new position).
if (!seeking && Date.now() > seekSuppressUntil) {
slider.value = data.position || 0; slider.value = data.position || 0;
el("time-pos").textContent = formatTime(data.position);
}
if (document.activeElement !== el("volume-slider")) { if (document.activeElement !== el("volume-slider")) {
el("volume-slider").value = data.volume || 0; el("volume-slider").value = data.volume || 0;
} }
if (Date.now() > keepSuppressUntil) {
el("keep-playing-checkbox").checked = !!data.keep_playing; el("keep-playing-checkbox").checked = !!data.keep_playing;
}
}); });
} }
@ -129,14 +120,6 @@
api("/api/control/playpause", { method: "POST" }); api("/api/control/playpause", { method: "POST" });
}); });
el("btn-prev").addEventListener("click", () => {
api("/api/control/previous", { method: "POST" });
});
el("btn-next").addEventListener("click", () => {
api("/api/control/next", { method: "POST" });
});
el("btn-back30").addEventListener("click", () => { el("btn-back30").addEventListener("click", () => {
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: -30 }) }); api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: -30 }) });
}); });
@ -145,20 +128,6 @@
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: 30 }) }); api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: 30 }) });
}); });
// Drag the progress bar to seek. 'input' fires while dragging (live label),
// 'change' fires on release (send the absolute seek).
el("seek-slider").addEventListener("input", (e) => {
seeking = true;
el("time-pos").textContent = formatTime(Number(e.target.value));
});
el("seek-slider").addEventListener("change", (e) => {
const pos = Number(e.target.value);
seeking = false;
seekSuppressUntil = Date.now() + 1200;
api("/api/control/seekto", { method: "POST", body: JSON.stringify({ position: pos }) });
});
el("volume-slider").addEventListener("input", (e) => { el("volume-slider").addEventListener("input", (e) => {
clearTimeout(volumeDebounce); clearTimeout(volumeDebounce);
const value = e.target.value; const value = e.target.value;
@ -168,7 +137,6 @@
}); });
el("keep-playing-checkbox").addEventListener("change", (e) => { el("keep-playing-checkbox").addEventListener("change", (e) => {
keepSuppressUntil = Date.now() + 1500;
api("/api/control/keep-playing", { api("/api/control/keep-playing", {
method: "POST", method: "POST",
body: JSON.stringify({ enabled: e.target.checked }), body: JSON.stringify({ enabled: e.target.checked }),

View file

@ -27,15 +27,13 @@
<p id="now-playing" class="filename">&mdash;</p> <p id="now-playing" class="filename">&mdash;</p>
<div class="progress-row"> <div class="progress-row">
<span id="time-pos">0:00</span> <span id="time-pos">0:00</span>
<input id="seek-slider" type="range" min="0" max="0" step="1"> <input id="seek-slider" type="range" min="0" max="0" step="1" disabled>
<span id="time-dur">0:00</span> <span id="time-dur">0:00</span>
</div> </div>
<div class="transport-row"> <div class="transport-row">
<button id="btn-prev" title="Previous clip">&#9198;</button>
<button id="btn-back30">&laquo; 30</button> <button id="btn-back30">&laquo; 30</button>
<button id="btn-playpause">Play/Pause</button> <button id="btn-playpause">Play/Pause</button>
<button id="btn-fwd30">30 &raquo;</button> <button id="btn-fwd30">30 &raquo;</button>
<button id="btn-next" title="Next clip">&#9197;</button>
</div> </div>
<div class="volume-row"> <div class="volume-row">
<span>Vol</span> <span>Vol</span>

View file

@ -1,76 +0,0 @@
#!/usr/bin/env bash
#
# mediapi-watchdog -- reboot the Pi if the mpv player wedges.
#
# Guards against the failure seen 2026-07-09: a kernel oops left the player
# stuck in uninterruptible (D) state while the rest of the box -- systemd, SSH,
# the Flask app -- stayed alive and responsive. A plain systemd/hardware
# watchdog only fires on a TOTAL system hang, so it would NOT have caught that
# partial wedge. Instead we ping mpv over its JSON IPC socket; if mpv stays
# unresponsive for ~3 minutes we force a reboot, turning a dead-all-night wedge
# into a ~30s auto-recovery.
#
# Runs as root (needs to force a reboot). Installed + enabled by install.sh.
set -u
SOCK="${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"
INTERVAL="${MEDIAPI_WATCHDOG_INTERVAL:-30}" # seconds between checks
FAILS_TO_REBOOT="${MEDIAPI_WATCHDOG_FAILS:-6}" # consecutive fails -> reboot (~3 min)
PING_TIMEOUT="${MEDIAPI_WATCHDOG_PING_TIMEOUT:-6}" # per-check hard timeout
DRYRUN="${MEDIAPI_WATCHDOG_DRYRUN:-}" # non-empty: log instead of rebooting
# Return 0 iff mpv replies to a JSON IPC command within PING_TIMEOUT. A player
# wedged in D-state accepts the socket connect but never replies, so the recv
# blocks and `timeout` trips it -- exactly the case we want to catch.
ping_mpv() {
timeout "$PING_TIMEOUT" python3 - "$SOCK" <<'PY'
import socket, json, sys
try:
s = socket.socket(socket.AF_UNIX); s.settimeout(4); s.connect(sys.argv[1])
s.sendall(b'{"command":["get_property","mpv-version"],"request_id":1}\n')
buf = b""
while b"\n" not in buf:
chunk = s.recv(4096)
if not chunk:
sys.exit(1)
buf += chunk
for line in buf.decode(errors="replace").splitlines():
m = json.loads(line)
if m.get("request_id") == 1:
sys.exit(0 if m.get("error") == "success" else 1)
sys.exit(1)
except Exception:
sys.exit(1)
PY
}
do_reboot() {
if [ -n "$DRYRUN" ]; then
echo "mediapi-watchdog: DRYRUN -- would reboot now" >&2
return
fi
# Best-effort clean reboot first; fall back to SysRq, which reboots at the
# kernel level even when userspace is wedged in D-state (the case we guard).
sync &
systemctl reboot -ff &
sleep 12
echo 1 > /proc/sys/kernel/sysrq 2>/dev/null || true
echo b > /proc/sysrq-trigger 2>/dev/null || true
}
echo "mediapi-watchdog: watching $SOCK (reboot after ${FAILS_TO_REBOOT}x${INTERVAL}s unresponsive)" >&2
fails=0
while true; do
if ping_mpv; then
fails=0
else
fails=$((fails + 1))
echo "mediapi-watchdog: mpv ping FAILED ($fails/$FAILS_TO_REBOOT)" >&2
if [ "$fails" -ge "$FAILS_TO_REBOOT" ]; then
echo "mediapi-watchdog: mpv unresponsive ~$((INTERVAL * FAILS_TO_REBOOT))s -- rebooting" >&2
do_reboot
fails=0
fi
fi
sleep "$INTERVAL"
done

171
scripts/start-mpv.py Executable file
View file

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Launch one mpv per connected HDMI output so playback mirrors across every
attached display.
mpv's DRM output can only ever drive a single connector, and the Pi's vc4-kms
driver can't clone two HDMI connectors onto one framebuffer -- so "mirror on
both screens" means running one mpv per connector. This script enumerates the
connected connectors at start and launches:
* a PRIMARY mpv on the first connected connector -- owns audio and the IPC
socket the Flask app talks to (/run/mediapi/mpv.sock);
* a MIRROR mpv on each additional connector -- video only (--ao=null), each
on its own IPC socket (/run/mediapi/mpv-mirror-<connector>.sock) so the app
can echo loadfile/pause/seek to it best-effort.
Design choices, all in service of "must work unattended in a car":
* If no connector can be detected (unexpected sysfs naming, etc.) it falls
back to a single auto-picking mpv -- i.e. exactly the old behavior, never
worse.
* It supervises the children: if any mpv exits, it tears the rest down and
exits non-zero so systemd restarts the whole unit (which re-enumerates
displays -- e.g. a screen powered on since last start).
* The mirror is strictly best-effort; the primary is what carries audio and
control, so a dead mirror degrades to single-screen playback.
Runs as a plain stdlib script (no venv needed) so it can start early at boot.
"""
import glob
import os
import re
import signal
import subprocess
import sys
import time
RUNTIME_DIR = os.environ.get("MEDIAPI_RUNTIME_DIR", "/run/mediapi")
PRIMARY_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", os.path.join(RUNTIME_DIR, "mpv.sock"))
# Flags shared by every instance. Kept in sync with what the mpv systemd unit
# used to pass directly.
COMMON_ARGS = [
"/usr/bin/mpv",
"--idle=yes",
"--vo=gpu-next",
"--gpu-context=drm",
"--hwdec=auto-safe", # falls back to software if the HW decoder is busy
"--fullscreen",
"--no-terminal",
"--keep-open=no",
]
def connected_hdmi_outputs():
"""Return [(card_device, connector_name), ...] for every connected HDMI
connector, e.g. [("/dev/dri/card1", "HDMI-A-1"), ...], sorted by connector
name so the assignment of primary/mirror is stable across restarts."""
outputs = []
for status_path in sorted(glob.glob("/sys/class/drm/card*-HDMI-*/status")):
try:
with open(status_path) as f:
status = f.read().strip()
except OSError:
continue
if status != "connected":
continue
# .../card1-HDMI-A-1/status -> card="card1", connector="HDMI-A-1"
m = re.match(r"(card\d+)-(HDMI-\S+)", os.path.basename(os.path.dirname(status_path)))
if not m:
continue
card, connector = m.group(1), m.group(2)
outputs.append((f"/dev/dri/{card}", connector))
return outputs
def mirror_socket_path(connector):
return os.path.join(os.path.dirname(PRIMARY_SOCKET), f"mpv-mirror-{connector}.sock")
def build_commands():
"""Build the list of mpv argv lists to launch."""
outputs = connected_hdmi_outputs()
if not outputs:
# Couldn't identify any connector -- fall back to a single auto-picking
# mpv (the old behavior). Never worse than before.
print("start-mpv: no HDMI connector detected, launching single auto mpv", flush=True)
return [COMMON_ARGS + [f"--input-ipc-server={PRIMARY_SOCKET}", "--ao=alsa"]]
commands = []
primary_card, primary_connector = outputs[0]
print(f"start-mpv: primary on {primary_connector} ({primary_card})", flush=True)
commands.append(
COMMON_ARGS
+ [
f"--drm-device={primary_card}",
f"--drm-connector={primary_connector}",
f"--input-ipc-server={PRIMARY_SOCKET}",
"--ao=alsa",
]
)
for card, connector in outputs[1:]:
sock = mirror_socket_path(connector)
print(f"start-mpv: mirror on {connector} ({card}) -> {sock}", flush=True)
commands.append(
COMMON_ARGS
+ [
f"--drm-device={card}",
f"--drm-connector={connector}",
f"--input-ipc-server={sock}",
"--ao=null",
"--mute=yes",
]
)
return commands
def main():
# Clean up any stale mirror sockets from a previous run so the app doesn't
# try to talk to a connector that's no longer attached.
for stale in glob.glob(os.path.join(os.path.dirname(PRIMARY_SOCKET), "mpv-mirror-*.sock")):
try:
os.unlink(stale)
except OSError:
pass
procs = [subprocess.Popen(cmd) for cmd in build_commands()]
stopping = {"flag": False}
def kill_children():
for p in procs:
if p.poll() is None:
p.terminate()
def on_signal(*_):
# Told to stop (systemctl stop / Ctrl-C): kill the children and let
# main exit 0 so systemd treats it as a clean stop, not a crash.
stopping["flag"] = True
kill_children()
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
# Poll the children (via subprocess so its bookkeeping stays consistent --
# don't os.wait() out from under it). The first unexpected exit means a
# screen dropped its mpv: tear the rest down and exit non-zero so systemd
# restarts the unit, re-enumerating displays in the process. time.sleep is
# interrupted by SIGTERM, so a stop is handled promptly too.
while not stopping["flag"]:
dead = next((p for p in procs if p.poll() is not None), None)
if dead is not None:
print(f"start-mpv: an mpv exited unexpectedly (pid {dead.pid}), restarting unit", flush=True)
kill_children()
break
time.sleep(1)
# Reap whatever's left.
for p in procs:
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
sys.exit(0 if stopping["flag"] else 1)
if __name__ == "__main__":
main()

View file

@ -1,39 +1,26 @@
[Unit] [Unit]
Description=mpv video player (KMS/DRM -- the actual player) Description=mpv persistent player (DRM/HDMI output, JSON IPC)
# Needs the DRM/HDMI + sound devices present. # Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist
After=local-fs.target systemd-udev-settle.service sound.target # before mpv tries to grab the display. Without this, a cold-boot race can
# leave mpv running but never rendering to HDMI (you see the console instead),
# even though the exact same command works when run by hand later.
After=local-fs.target systemd-udev-settle.service
Wants=systemd-udev-settle.service Wants=systemd-udev-settle.service
# Keep a login getty off the primary display so nothing else claims tty1.
Conflicts=getty@tty1.service
[Service] [Service]
Type=simple
User=${MEDIAPI_USER} User=${MEDIAPI_USER}
# mpv on KMS/DRM needs: video+render (GPU/DRM), input (remotes/keyboard), audio SupplementaryGroups=video render
# (HDMI/ALSA). It becomes DRM master simply as the first/only DRM client on the
# seat -- no PAM login session and no elevated capabilities required, provided
# nothing else is already holding the GPU. (An older Kodi-based deploy DID hold
# it; install.sh tears any leftover Kodi down before starting this.)
SupplementaryGroups=video render input audio
# Create /run/mediapi (owned by this user) for the IPC socket the app connects to.
RuntimeDirectory=mediapi RuntimeDirectory=mediapi
StandardInput=null RuntimeDirectoryMode=0770
StandardOutput=journal # start-mpv.py enumerates the connected HDMI connectors and launches one mpv
StandardError=journal # per screen: the primary carries audio + the app's IPC socket, and each extra
# --idle keeps mpv running with no file loaded (holds the playlist between clips # screen gets a video-only mirror mpv on its own socket. If any mpv dies the
# and after the last one), so the service stays up and the app can always reach # launcher exits non-zero and systemd restarts us, which re-enumerates the
# the socket. Playback renders straight on KMS. # displays. See the script header for the full rationale.
ExecStart=/usr/bin/mpv \ ExecStart=/usr/bin/python3 ${PROJECT_DIR}/scripts/start-mpv.py
--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 Restart=on-failure
RestartSec=5 RestartSec=2
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,15 +0,0 @@
[Unit]
Description=MediaPi watchdog -- reboots the Pi if the mpv player wedges
After=mediapi-mpv.service
Wants=mediapi-mpv.service
[Service]
Type=simple
# Runs as root (no User=): must be able to force a reboot, including via SysRq,
# to recover a box whose player is wedged in uninterruptible D-state.
ExecStart=${PROJECT_DIR}/scripts/mediapi-watchdog.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target