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>
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>
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>
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>
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>
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>
- 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>
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>
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>
Cold-boot race could leave mpv running but not rendering to HDMI (console
stays visible) even though the same command works when run manually later.
Order the unit after systemd-udev-settle so /dev/dri/* exists first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move user-specific config (login, media roots, port, AP ssid/psk, service
user) into a gitignored .env; add .env.example template.
- config.py reads .env at startup via a tiny zero-dependency parser (works for
both `uv run` and systemd), and fails loudly if required vars are unset.
- load_secret_key falls back to an ephemeral key on a read-only filesystem
instead of crashing.
- systemd units become .template files; deploy.sh renders them with the
.env-derived user/paths.
- deploy.sh: refuses to run on a read-only overlay, git pull --ff-only,
uv sync, ensure secret key, re-apply the AP from .env, install units,
restart, health-check with automatic rollback to the previous commit.
- Scrub literal credentials/ssid out of the README; document .env + deploy.sh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Raspberry Pi 4 appliance that boots as a WiFi access point and serves a
phone-controlled web app for browsing /localmedia and driving video
playback out the Pi's HDMI port.
- NetworkManager-based AP setup (documented in README)
- Flask app + vanilla HTML/JS single-page UI (Control / Playback modes)
- mpv persistent daemon (DRM/KMS HDMI output) controlled over JSON IPC
- long-lived session-cookie auth
- "keep playing" folder auto-advance, including when started mid-folder
- systemd units for both mpv and the app, autostart on boot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>