Compare commits

...

9 commits

Author SHA1 Message Date
Jean-Michel Tremblay
9d52e46d1e Add mediapi-watchdog: reboot the Pi if the mpv player wedges
On 2026-07-09 a kernel keyring-GC oops left the player stuck in
uninterruptible D-state while systemd, SSH, and the Flask app stayed
alive -- so the box sat dead all night instead of recovering. A plain
systemd/hardware watchdog only fires on a TOTAL hang and would not have
caught that partial wedge.

mediapi-watchdog.service (Type=simple, Restart=always, runs as root)
pings mpv over its JSON IPC socket every 30s; after ~3 min of continuous
failure it forces a reboot (systemctl reboot -ff, then SysRq as a
kernel-level fallback that works even when userspace is wedged). A
D-state mpv accepts the socket connect but never replies, so the ping
times out and is caught. install.sh installs + enables it.

Verified: no false positives while mpv is healthy; correctly detects a
stopped mpv and reaches the reboot decision (tested in dry-run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:03:45 -04:00
Jean-Michel Tremblay
799a5d5467 Fix mpv never starting: drop the PAMName=login hang, tear down old Kodi
The mpv service inherited Kodi's VT-grabbing block (PAMName=login +
StandardInput=tty + TTYPath=/dev/tty1). Opening a "login" PAM session on
tty1 hangs in systemd's pre-exec setup, so mpv was never exec'd at all --
the service sat "running" as systemd-executor with an empty (mpv) cmdline,
no IPC socket, no output.

Two fixes:

1. Simplify the unit. mpv doesn't need a login session or the tty: as the
   sole DRM client on the seat it becomes DRM master implicitly on first
   open, so plain video+render+audio group membership is enough. Removing
   the PAM/tty block lets mpv actually start, initialize the vc4 KMS
   display, and play (verified end-to-end: play/status/next through the
   Flask API, position advancing on screen).

2. install.sh now tears down any leftover Kodi before starting mpv. The
   real trigger this time was an in-place migration: replacing the unit
   files doesn't stop an already-running Kodi, which kept DRM master and
   the tty1 seat and blocked mpv from ever getting the display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:04:45 -04:00
Jean-Michel Tremblay
5972ac2736 Replace Kodi with mpv as the player; drive it over its JSON IPC socket
Kodi was overkill for a phone-driven "play this video" box: a full media
center (web server, CEC, library DB) whose surface area is exactly what
wedged the Pi -- a video-decode session left Kodi stuck in an
uninterruptible firmware-mailbox call after a kernel keyring Oops, dead
until a power cycle.

mpv is just a video player: hardware-decoded straight on KMS/DRM, no
media-center baggage. It runs as its own --idle systemd service holding
the playlist, so playback keeps going even if the app/phone/WiFi drop --
the same autonomy Kodi's native playlist gave us. The app talks to it
over its JSON IPC Unix socket.

PlayerStateManager keeps the exact same public API, so the Flask routes,
templates, and UI are unchanged (only the engine underneath swaps out).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:16:20 -04:00
Jean-Michel Tremblay
b610fc9652 Drive playback via Kodi's native playlist so it never depends on the app
Playback stopped advancing whenever the mediapi app disconnected/restarted,
because auto-advance ran in the app's poll loop. Move it into Kodi: playing a
file/folder loads the whole folder into Kodi's video playlist and starts it, so
Kodi advances (and loops, when keep-playing is on) entirely on its own -- it
keeps running even if the app, phone, or WiFi drop.

- play_file/play_folder build the Kodi video playlist (Playlist.Clear/Add +
  Player.Open at the chosen position); remove the in-app queue + poll-advance
- next/previous are now Player.GoTo next/previous (playlist navigation)
- keep-playing maps to Kodi repeat: on=all (loop folder), off=play through once;
  default ON, applied via Player.SetRepeat
- checkbox no longer clobbered by the poll for 1.5s after a manual toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:53:01 -04:00
Jean-Michel Tremblay
f65600cff7 Make the progress bar draggable to seek to a position
The seek slider was display-only; enable it as a scrubber. Adds absolute-seek
in the backend (PlayerStateManager.seek_to via Kodi Player.Seek with an absolute
time) and the /api/control/seekto route. The slider sends the seek on release;
the poll loop stops overwriting the slider while dragging and for a short window
after a seek so it doesn't snap back before Kodi reports the new position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:15:09 -04:00
Jean-Michel Tremblay
1290f784e7 Add Next/Previous clip buttons
Skip forward/back through the current folder queue -- the player already keeps
the folder as an ordered queue with an index, so next/previous just move within
it (no-op at the ends). New PlayerStateManager.next/previous/skip, the
/api/control/next|previous routes, and compact ⏮/⏭ buttons in the transport row.

Lets you skip clips completely without a resume/history feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:53:35 -04:00
Jean-Michel Tremblay
99b5c27e65 install.sh: route Kodi audio to HDMI + ensure media roots exist
- After Kodi starts, set audiooutput.audiodevice to the HDMI sink via JSON-RPC
  (Kodi otherwise defaults to the analog jack). Waits for the web server, is
  overridable via MEDIAPI_KODI_AUDIO_DEVICE.
- mkdir the configured MEDIAPI_MEDIA_ROOTS so browsing works and there's a spot
  to copy content into.

Verified live on the Pi: playback + HDMI audio work end to end via the app's
Player.Open path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:09:08 -04:00
Jean-Michel Tremblay
afe1ab3e2f 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>
2026-07-07 22:42:04 -04:00
Jean-Michel Tremblay
ee72d8b735 Drive both HDMI outputs via X kiosk + single mirrored mpv
The Pi 4's two HDMI connectors share one vc4 DRM card, and DRM master is
exclusive per card -- so the old per-connector-mpv design could never light
the second screen (the mirror mpv died with "Failed to acquire DRM master:
Permission denied"). Replace it with a minimal X server started by the
mediapi-mpv unit via xinit: X is the single DRM master, xrandr --same-as
clones the first output onto every other connected HDMI, and one fullscreen
mpv renders to both. Uses hwdec=v4l2m2m-copy (Pi HW decoder, ~1/3 the CPU of
software) and carries audio + the app's sole IPC socket.

- new scripts/mediapi-session.sh (X client: waits for connectors, mirrors,
  execs mpv); rewritten mediapi-mpv unit (xinit on VT7)
- delete scripts/start-mpv.py and all mirror-socket/broadcast code in
  player.py, config.py, __init__.py -- a single mpv means one socket
- install.sh installs xserver-xorg-core/xinit/x11-xserver-utils, writes
  /etc/X11/Xwrapper.config, adds the user to input,tty
- README + troubleshooting updated

Verified from a cold boot on the Pi: both pixelvalve CRTCs scan the same
framebuffer; app healthy; HW decode engaged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:59:05 -04:00
16 changed files with 514 additions and 475 deletions

View file

@ -12,6 +12,11 @@ MEDIAPI_PASSWORD=changeme
MEDIAPI_MEDIA_ROOTS=/localmedia MEDIAPI_MEDIA_ROOTS=/localmedia
MEDIAPI_PORT=8080 MEDIAPI_PORT=8080
# --- mpv (the actual player) ---
# mediapi drives mpv over its JSON IPC Unix socket. install.sh installs mpv and
# runs it as a service on KMS with --input-ipc-server pointed at this socket.
#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock
# --- System: the Linux user the systemd services run as --- # --- System: the Linux user the systemd services run as ---
# Auto-detected from whoever runs install.sh (id -un) when left unset, so you # 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. # normally don't need this. Uncomment only to force a specific user.
@ -23,11 +28,3 @@ 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,45 @@ Keep values simple (no spaces / shell-special characters).
## Setup (mediapi app) ## Setup (mediapi app)
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv` Playback is done by **Kodi** (`mediapi-kodi` unit), running standalone on
player controlled over its JSON IPC socket. mpv renders video to HDMI GBM/KMS straight on the hardware — the same smooth, hardware-decoded path as
directly (DRM/KMS, no desktop needed); the phone browser only shows LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is
metadata/controls, never the video image itself. 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.** A single mpv on DRM can only drive one connector, and Kodi handles decode, HDMI audio and display; mediapi keeps a background poll of
the Pi's `vc4-kms` driver can't clone two HDMI outputs onto one framebuffer — Kodi's player state (position/duration/pause/volume) and drives "keep playing"
so mirroring is done by running *one mpv per connected screen*. auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and
`scripts/start-mpv.py` (launched by the `mediapi-mpv` unit) enumerates the enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see
connected HDMI connectors and starts a **primary** mpv (audio + the app's IPC `scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env`
socket, `mpv.sock`) plus a **mirror** mpv per extra screen (video-only, on (`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`.
`mpv-mirror-<connector>.sock`). The app plays/pauses/seeks the primary and
echoes those to the mirrors best-effort, re-syncing on every video. One screen > **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software
→ same as before; a dead mirror just leaves that screen dark, never the audio > (DRM master is exclusive per card; the X-mirror path can't keep up). Drive
screen. Because each screen runs its own mpv, the same file decodes once per > both car screens from one HDMI port through an external powered HDMI splitter.
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
# 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: syncs deps, adds the service user to # deploy the currently-checked-out ref: installs Kodi + Python deps, enables
# the video+render groups (GPU/DRM access for HDMI), 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 # Run as your normal user -- NOT with sudo, or the services get rendered to run
# the services get rendered to run as root. # as root.
./install.sh ./install.sh
# verify # verify
systemctl status mediapi-mpv mediapi-app systemctl status mediapi-kodi mediapi-app
ls -l /run/mediapi/mpv.sock 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 Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
@ -192,20 +192,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 (couldn't be verified Notes / things to double check on the actual hardware:
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 Bookworm Pi4 images (needed for DRM output) — worth a quick check. on current Pi4 images (needed for KMS output) — worth a quick check.
* Mirroring picks up whatever HDMI connectors read `connected` under * Kodi runs standalone on GBM as the `mediapi-kodi` service (on `tty1`). If the
`/sys/class/drm/card*-HDMI-*/status` at service start — so plug in both screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's
screens **before** `mediapi-mpv` starts (or `sudo systemctl restart own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the
mediapi-mpv` after). Check what it launched with `video render input audio tty` groups (install.sh adds them).
`sudo journalctl -u mediapi-mpv | grep start-mpv` and confirm both sockets: * If the web API is unreachable (`Connection refused` on
`ls -l /run/mediapi/mpv*.sock`. `mpv --drm-connector=help` (with a display `:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites
attached) lists the connector names if the sysfs guess is ever wrong. `guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the
* If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA setting via `scripts/configure-kodi.py`, and restarts), or toggle
device name (usually `vc4-hdmi`) and add e.g. Settings → Services → Control → *Allow remote control via HTTP* in the Kodi
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or GUI once. Verify with `JSONRPC.Ping` (see install snippet above).
run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output. * 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` 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:-}" 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 "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2 echo "WARNING: .env not found -- creating it from .env.example." >&2
exit 1 echo " Edit .env with your real AP/login passwords, then re-run." >&2
cp .env.example .env
fi fi
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@ -52,7 +55,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="$(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 ------------------------ # --- 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
@ -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)" 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; do for unit in mediapi-mpv mediapi-app mediapi-watchdog; 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" \
@ -116,14 +140,30 @@ deploy_current() {
chmod 600 instance/secret_key chmod 600 instance/secret_key
fi fi
# The mpv service runs as MEDIAPI_USER and needs the video+render groups to # mpv (the player) runs as MEDIAPI_USER on KMS/DRM and needs these groups to
# reach the GPU/DRM devices for HDMI output. Idempotent; systemd picks up the # reach the GPU/DRM, audio, input and console devices. Idempotent; systemd
# new membership when it (re)starts the service below, so no logout needed. # picks up the new membership when it (re)starts the service below.
if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then
echo "==> Adding '${MEDIAPI_USER}' to video,render groups ..." echo "==> Adding '${MEDIAPI_USER}' to video,render,input,audio,tty groups ..."
sudo usermod -aG video,render "${MEDIAPI_USER}" sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}"
fi 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
@ -142,11 +182,38 @@ deploy_current() {
ipv4.method shared ipv4.method shared
fi fi
echo "==> Installing systemd units + restarting services ..." echo "==> Installing systemd units ..."
install_units install_units
sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true sudo systemctl enable mediapi-mpv mediapi-app mediapi-watchdog >/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 ---------------------------------------------------
@ -161,6 +228,7 @@ app_healthy() {
} }
# --- 2-6. deploy ---------------------------------------------------- # --- 2-6. deploy ----------------------------------------------------
bootstrap_system
deploy_current deploy_current
# --- 7. health check + rollback ------------------------------------ # --- 7. health check + rollback ------------------------------------
@ -168,7 +236,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 || true systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app mediapi-watchdog || true
exit 0 exit 0
fi fi

View file

@ -2,6 +2,7 @@ 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
@ -18,11 +19,8 @@ def create_app():
register_auth_gate(app) register_auth_gate(app)
app.player = PlayerStateManager( mpv = MpvClient(app.config["MPV_SOCKET"])
app.config["MPV_SOCKET"], app.player = PlayerStateManager(mpv, app.config["VIDEO_EXTENSIONS"])
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,11 +75,10 @@ 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 = {

92
mediapi/mpv.py Normal file
View file

@ -0,0 +1,92 @@
"""Minimal mpv JSON IPC client over a Unix socket.
mpv does the actual playback (hardware-decoded, straight on KMS/DRM); mediapi is
just the phone-facing UI that drives it. mpv runs as its own systemd service
with `--idle --input-ipc-server=<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)

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

@ -1,11 +1,10 @@
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_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError from .mpv import MpvConnectionError, MpvError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -14,26 +13,28 @@ RECONNECT_INTERVAL = 2.0
class PlayerStateManager: class PlayerStateManager:
"""Owns the single connection to mpv's IPC socket. A background thread """Controls mpv over its JSON IPC socket and caches its playback state.
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."""
def __init__(self, socket_path, video_extensions, mirror_glob=None): Playback is driven through mpv's own PLAYLIST: playing a file or folder
self.socket_path = socket_path loads the whole folder into mpv's playlist and jumps to the chosen entry.
mpv then advances through the folder (and loops it, when "keep playing" is
on) ALL BY ITSELF -- so playback keeps going even if this app, the phone, or
the WiFi disconnect. mediapi only issues commands and polls state; it is
never in the playback loop. Next/Previous are mpv playlist navigation; "keep
playing" is mpv's `loop-playlist`.
"""
def __init__(self, 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,
@ -41,14 +42,9 @@ class PlayerStateManager:
"duration": None, "duration": None,
"paused": None, "paused": None,
"volume": None, "volume": None,
"keep_playing": False, "keep_playing": True,
} }
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()
@ -56,56 +52,47 @@ class PlayerStateManager:
def stop(self): def stop(self):
self._stop.set() self._stop.set()
# -- background loop ------------------------------------------------- # -- background loop (status only; mpv owns auto-advance) --------------
def _run(self): def _run(self):
while not self._stop.is_set(): 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: try:
self._poll_once() self._poll_once()
except MpvConnectionError as exc: except MpvConnectionError as exc:
log.warning("lost connection to mpv: %s", exc) log.debug("mpv not reachable: %s", exc)
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)
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 _get_property_safe(self, name): def _has_media(self):
"""Like client.get_property, but treats "property unavailable" """True if mpv currently has a file loaded (not idle). Raises
(normal for time-pos/duration/filename while mpv is idle) as None MpvConnectionError if mpv is unreachable."""
instead of a fatal error -- only a real MpvConnectionError should return self._mpv.try_get("path") is not None
tear down the connection."""
try:
return self._client.get_property(name)
except MpvCommandError:
return None
def _poll_once(self): def _poll_once(self):
idle = bool(self._get_property_safe("idle-active")) # `path` is unavailable while mpv sits idle; try_get returns None then.
filename = self._get_property_safe("filename") # A genuine socket failure raises MpvConnectionError and marks us down.
position = self._get_property_safe("time-pos") path = self._mpv.try_get("path") # raises MpvConnectionError if down
duration = self._get_property_safe("duration")
paused = self._get_property_safe("pause")
volume = self._get_property_safe("volume")
if idle and not self._prev_idle: filename = position = duration = None
self._maybe_advance() paused = None
idle = self._get_property_safe("idle-active")
self._prev_idle = bool(idle) if path is not None:
filename = os.path.basename(path) or self._mpv.try_get("media-title")
position = self._mpv.try_get("time-pos")
duration = self._mpv.try_get("duration")
paused = bool(self._mpv.try_get("pause", False))
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({
@ -115,108 +102,83 @@ 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:
state = dict(self._state) return dict(self._state)
state["keep_playing"] = self._state["keep_playing"]
return state
# -- mirror screens (best-effort) -------------------------------------- # -- helpers -----------------------------------------------------------
def _broadcast(self, *command_args): def _play_playlist(self, files, start_index):
"""Echo a playback command to every mirror mpv, best-effort. Never """Load `files` into mpv's playlist (folder order) and start at
raises: a mirror that's absent, mid-restart, or wedged must not affect start_index. mpv advances through them on its own from here on."""
the primary screen or the HTTP response. Mirrors are re-synced on every # `loadfile ... replace` starts a fresh playlist with the first file;
loadfile, so a command missed here self-heals at the next video.""" # append the rest to rebuild the folder in order, then jump to the
if not self._mirror_glob: # chosen entry so the playlist matches the folder exactly.
return self._mpv.command("loadfile", files[0], "replace")
with self._mirror_lock: for f in files[1:]:
paths = set(glob.glob(self._mirror_glob)) self._mpv.command("loadfile", f, "append")
# Drop clients whose socket disappeared (display unplugged). if start_index > 0:
for gone in set(self._mirror_clients) - paths: self._mpv.set_property("playlist-pos", start_index)
self._mirror_clients.pop(gone).close() self._apply_repeat()
for path in paths:
client = self._mirror_clients.get(path) def _apply_repeat(self):
if client is None: """Set mpv's playlist loop to match keep-playing."""
client = self._mirror_clients[path] = MpvIPCClient(path) self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no")
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):
self._client.command("loadfile", path, "replace") # Queue the whole containing folder so playback continues through the
self._broadcast("loadfile", path, "replace") # rest of it, starting at the chosen file.
# 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
with self._lock: self._play_playlist(files, index)
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._client.command("loadfile", files[0], "replace") self._play_playlist(files, 0)
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):
self._client.command("cycle", "pause") if self._has_media():
self._broadcast("cycle", "pause") self._mpv.command("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):
self._client.command("seek", offset_seconds, "relative") if self._has_media():
self._broadcast("seek", offset_seconds, "relative") self._mpv.command("seek", int(offset_seconds), "relative")
def seek_to(self, position_seconds):
"""Seek to an absolute position (seconds from the start) -- used by the
draggable progress bar."""
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._client.set_property("volume", value) self._mpv.set_property("volume", value)
def set_keep_playing(self, enabled): def set_keep_playing(self, enabled):
with self._lock: with self._lock:
self._state["keep_playing"] = bool(enabled) self._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 MpvIPCError as exc: 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})
@ -52,7 +52,25 @@ def play():
def playpause(): def playpause():
try: try:
current_app.player.playpause() current_app.player.playpause()
except MpvIPCError as exc: except MpvError 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})
@ -67,7 +85,22 @@ def seek():
try: try:
current_app.player.seek(offset) current_app.player.seek(offset)
except MpvIPCError as exc: except MpvError 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})
@ -82,7 +115,7 @@ def volume():
try: try:
current_app.player.set_volume(value) current_app.player.set_volume(value)
except MpvIPCError as exc: 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})

View file

@ -135,18 +135,26 @@ html, body {
.transport-row { .transport-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.4rem;
margin: 1rem 0; margin: 1rem 0;
} }
.transport-row button { .transport-row button {
flex: 1; flex: 1;
padding: 0.8rem; padding: 0.8rem 0.3rem;
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,6 +3,9 @@
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);
@ -102,17 +105,23 @@
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;
}
}); });
} }
@ -120,6 +129,14 @@
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 }) });
}); });
@ -128,6 +145,20 @@
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;
@ -137,6 +168,7 @@
}); });
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,13 +27,15 @@
<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" disabled> <input id="seek-slider" type="range" min="0" max="0" step="1">
<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>

76
scripts/mediapi-watchdog.sh Executable file
View file

@ -0,0 +1,76 @@
#!/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

View file

@ -1,171 +0,0 @@
#!/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,26 +1,39 @@
[Unit] [Unit]
Description=mpv persistent player (DRM/HDMI output, JSON IPC) Description=mpv video player (KMS/DRM -- the actual player)
# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist # Needs the DRM/HDMI + sound devices present.
# before mpv tries to grab the display. Without this, a cold-boot race can After=local-fs.target systemd-udev-settle.service sound.target
# 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}
SupplementaryGroups=video render # mpv on KMS/DRM needs: video+render (GPU/DRM), input (remotes/keyboard), audio
# (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
RuntimeDirectoryMode=0770 StandardInput=null
# start-mpv.py enumerates the connected HDMI connectors and launches one mpv StandardOutput=journal
# per screen: the primary carries audio + the app's IPC socket, and each extra StandardError=journal
# screen gets a video-only mirror mpv on its own socket. If any mpv dies the # --idle keeps mpv running with no file loaded (holds the playlist between clips
# launcher exits non-zero and systemd restarts us, which re-enumerates the # and after the last one), so the service stays up and the app can always reach
# displays. See the script header for the full rationale. # the socket. Playback renders straight on KMS.
ExecStart=/usr/bin/python3 ${PROJECT_DIR}/scripts/start-mpv.py ExecStart=/usr/bin/mpv \
--idle=yes \
--input-ipc-server=/run/mediapi/mpv.sock \
--no-terminal \
--no-input-default-bindings \
--fullscreen \
--force-window=yes \
--keep-open=no \
--vo=gpu --gpu-context=drm --hwdec=auto \
--ao=alsa --audio-device=alsa/hdmi:CARD=vc4hdmi0,DEV=0
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

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