Compare commits

..

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

16 changed files with 366 additions and 514 deletions

View file

@ -12,11 +12,6 @@ MEDIAPI_PASSWORD=changeme
MEDIAPI_MEDIA_ROOTS=/localmedia
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 ---
# 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.
@ -28,3 +23,11 @@ MEDIAPI_AP_SSID=changeme
MEDIAPI_AP_PASSWORD=changeme
MEDIAPI_AP_CONN_NAME=mediapi-ap
# --- Advanced (leave unset unless you know you need them) ---
# Where the mpv IPC socket + runtime dir live. Defaults below are correct for
# the shipped systemd unit, which hardcodes RuntimeDirectory=mediapi (=/run/mediapi).
# If you override MEDIAPI_RUNTIME_DIR you MUST also edit
# systemd/mediapi-mpv.service.template's RuntimeDirectory to match, or the dir
# won't be created at boot. Most deployments should leave both commented out.
#MEDIAPI_RUNTIME_DIR=/run/mediapi
#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock

View file

@ -110,45 +110,46 @@ Keep values simple (no spaces / shell-special characters).
## Setup (mediapi app)
Playback is done by **Kodi** (`mediapi-kodi` unit), running standalone on
GBM/KMS straight on the hardware — the same smooth, hardware-decoded path as
LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is
just the phone-facing **remote**: it browses the media folders and drives Kodi
over its **JSON-RPC HTTP API** (`Player.Open`, `Player.PlayPause`,
`Player.Seek`, `Application.SetVolume`, `Player.GetProperties`). The phone
browser only shows metadata/controls, never the video image itself.
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
player controlled over its JSON IPC socket. mpv renders video to HDMI; the
phone browser only shows metadata/controls, never the video image itself.
Kodi handles decode, HDMI audio and display; mediapi keeps a background poll of
Kodi's player state (position/duration/pause/volume) and drives "keep playing"
auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and
enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see
`scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env`
(`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`.
> **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software
> (DRM master is exclusive per card; the X-mirror path can't keep up). Drive
> both car screens from one HDMI port through an external powered HDMI splitter.
**Dual-HDMI mirroring.** The Pi 4's two HDMI connectors share a single vc4 DRM
card, and DRM *master* is exclusive per card — so two independent mpv processes
can't both drive a screen (the second gets `Failed to acquire DRM master`).
Instead the `mediapi-mpv` unit runs a minimal **X server** via `xinit`
(`scripts/mediapi-session.sh`): X is the one DRM master, `xrandr --same-as`
clones the first output's framebuffer onto every other connected HDMI output,
and a **single** fullscreen mpv renders into it — so the same frames appear on
both screens, decoded once. mpv carries audio and the app's only IPC socket
(`/run/mediapi/mpv.sock`); `--hwdec=v4l2m2m-copy` uses the Pi's hardware H.264/
HEVC decoder (~⅓ the CPU of software decoding), falling back to software for
codecs it can't handle. The session waits for connectors to probe as connected
before mirroring, so a cold-boot HDMI race doesn't drop it to one screen. One
screen attached → just that screen, no config needed.
### First-time install (on the Pi)
```bash
# system deps: mpv for playback (install.sh installs the minimal X server itself)
sudo apt update
sudo apt install -y mpv
# install uv (Python package/venv manager) if not already present
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
# create your .env (see Configuration above)
cp .env.example .env && $EDITOR .env
# deploy the currently-checked-out ref: installs Kodi + Python deps, enables
# Kodi's JSON-RPC web server, configures the AP, installs+starts the services.
# Run as your normal user -- NOT with sudo, or the services get rendered to run
# as root.
# deploy the currently-checked-out ref: syncs deps, adds the service user to
# the video+render groups (GPU/DRM access for HDMI), configures the AP,
# installs+starts the services. Run as your normal user -- NOT with sudo, or
# the services get rendered to run as root.
./install.sh
# verify
systemctl status mediapi-kodi mediapi-app
curl -s "http://kodi:$(grep KODI_PASSWORD .env|cut -d= -f2)@127.0.0.1:8090/jsonrpc" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' # -> {"result":"pong"}
systemctl status mediapi-mpv mediapi-app
ls -l /run/mediapi/mpv.sock
```
Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
@ -192,20 +193,25 @@ cd ~/mediapi && ./install.sh v1.2.0
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
```
Notes / things to double check on the actual hardware:
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`
on current Pi4 images (needed for KMS output) — worth a quick check.
* Kodi runs standalone on GBM as the `mediapi-kodi` service (on `tty1`). If the
screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's
own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the
`video render input audio tty` groups (install.sh adds them).
* If the web API is unreachable (`Connection refused` on
`:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites
`guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the
setting via `scripts/configure-kodi.py`, and restarts), or toggle
Settings → Services → Control → *Allow remote control via HTTP* in the Kodi
GUI once. Verify with `JSONRPC.Ping` (see install snippet above).
* Kodi handles HDMI audio itself (Settings → System → Audio). If there's no
sound, set the audio output device to the HDMI sink there.
on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
* Mirroring clones every HDMI output that reads `connected` at session start
onto the first. The session waits for connectors to probe (cold-boot race),
then runs `xrandr --same-as`. See what it did with
`sudo journalctl -u mediapi-mpv | grep mediapi-session`; inspect the outputs
live with `DISPLAY=:0 xrandr` (as the service user). Both screens should share
a common resolution; if they differ, `xrandr` clones at the first output's
mode. Confirm the one socket: `ls -l /run/mediapi/mpv.sock`.
* Non-root X requires `/etc/X11/Xwrapper.config` with `allowed_users=anybody`
(install.sh writes it). If the service crash-loops with `Could not create
server lock file: /tmp/.X0-lock`, a previous X died uncleanly — remove
`/tmp/.X0-lock` (and `/tmp/.X11-unix/X0`) and restart. The X log is at
`~/.local/share/xorg/Xorg.0.log`.
* If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA
device name (usually `vc4-hdmi`) and add e.g.
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
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:-}"
# --- load config ----------------------------------------------------
# First run on a fresh Pi may have no .env yet -- seed it from the example so the
# script can proceed, but make it loud: the defaults ship an insecure AP.
if [[ ! -f .env ]]; then
echo "WARNING: .env not found -- creating it from .env.example." >&2
echo " Edit .env with your real AP/login passwords, then re-run." >&2
cp .env.example .env
echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2
exit 1
fi
set -a
# shellcheck disable=SC1091
@ -55,7 +52,7 @@ set +a
MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}"
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 ------------------------
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)"
fi
# --- system bootstrap (fresh Pi: assume ONLY git is installed) -------
# Installs everything else the deploy needs so a bare Raspberry Pi OS Lite goes
# from "git clone + ./install.sh" to a working player. NetworkManager (nmcli)
# and raspi-config already ship on Pi OS. Sets the global UV path afterwards.
bootstrap_system() {
echo "==> apt update + base packages (curl, python3) ..."
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl ca-certificates python3
if ! command -v uv >/dev/null 2>&1 && [[ ! -x "$HOME/.local/bin/uv" ]]; then
echo "==> Installing uv ..."
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
if [[ ! -x "$UV" ]]; then
echo "ERROR: uv is still not available at '$UV' after install." >&2
exit 1
fi
}
# --- render + install systemd units (used again on rollback) --------
install_units() {
for unit in mediapi-mpv mediapi-app mediapi-watchdog; do
for unit in mediapi-mpv mediapi-app; do
sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \
-e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \
-e "s|\${UV}|${UV}|g" \
@ -140,29 +116,27 @@ deploy_current() {
chmod 600 instance/secret_key
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.
# The mpv service runs as MEDIAPI_USER and needs the video+render groups to
# reach the GPU/DRM devices for HDMI output, plus tty+input to own the VT and
# read input under X. Idempotent; systemd picks up the new membership when it
# (re)starts the service below, so no logout needed.
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}"
echo "==> Adding '${MEDIAPI_USER}' to video,render,input,tty groups ..."
sudo usermod -aG video,render,input,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
# The player runs a minimal X server so a single mpv can be mirrored across
# both HDMI outputs (one DRM master; two separate mpv on the shared vc4 card
# cannot -- see scripts/mediapi-session.sh). Install just the X server + xinit
# + xrandr, and allow the non-root service user to start X on its VT.
if ! command -v Xorg >/dev/null 2>&1; then
echo "==> Installing minimal X server (xserver-xorg-core, xinit, xrandr) ..."
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
xserver-xorg-core xinit x11-xserver-utils
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 "==> Configuring /etc/X11/Xwrapper.config (allow non-root X on the VT) ..."
printf 'allowed_users=anybody\nneeds_root_rights=yes\n' \
| sudo tee /etc/X11/Xwrapper.config >/dev/null
echo "==> Applying WiFi country + AP config ..."
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
@ -182,38 +156,11 @@ deploy_current() {
ipv4.method shared
fi
echo "==> Installing systemd units ..."
echo "==> Installing systemd units + restarting services ..."
install_units
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 enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true
sudo systemctl restart mediapi-mpv
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 ---------------------------------------------------
@ -228,7 +175,6 @@ app_healthy() {
}
# --- 2-6. deploy ----------------------------------------------------
bootstrap_system
deploy_current
# --- 7. health check + rollback ------------------------------------
@ -236,7 +182,7 @@ echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..."
if app_healthy; then
echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE"
echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}."
systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app mediapi-watchdog || true
systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app || true
exit 0
fi

View file

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

View file

@ -75,9 +75,8 @@ class Config:
MEDIA_ROOTS = [
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).
# A single mpv drives every HDMI output -- the X session mirrors the screens
# with xrandr (see scripts/mediapi-session.sh) -- so there is one socket.
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))

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

@ -4,7 +4,7 @@ import threading
import time
from .media import list_video_files
from .mpv import MpvConnectionError, MpvError
from .mpv_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError
log = logging.getLogger(__name__)
@ -13,28 +13,21 @@ RECONNECT_INTERVAL = 2.0
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
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
def __init__(self, socket_path, video_extensions):
self.socket_path = socket_path
self.video_extensions = video_extensions
# A single mpv drives every HDMI output (the X session mirrors the
# screens with xrandr -- see scripts/mediapi-session.sh), so there is
# exactly one socket to talk to.
self._client = MpvIPCClient(socket_path)
self._lock = threading.Lock()
self._stop = threading.Event()
# 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 = {
"connected": False,
"filename": None,
@ -42,9 +35,14 @@ class PlayerStateManager:
"duration": None,
"paused": 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):
thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True)
thread.start()
@ -52,47 +50,56 @@ class PlayerStateManager:
def stop(self):
self._stop.set()
# -- background loop (status only; mpv owns auto-advance) --------------
# -- background loop -------------------------------------------------
def _run(self):
while not self._stop.is_set():
if not self._client.connected:
try:
self._client.connect()
log.info("connected to mpv socket at %s", self.socket_path)
except OSError:
self._set_disconnected()
time.sleep(RECONNECT_INTERVAL)
continue
try:
self._poll_once()
except MpvConnectionError as exc:
log.debug("mpv not reachable: %s", exc)
log.warning("lost connection to mpv: %s", exc)
self._set_disconnected()
time.sleep(RECONNECT_INTERVAL)
continue
except MpvError as exc:
log.warning("mpv poll error: %s", exc)
time.sleep(POLL_INTERVAL)
def _set_disconnected(self):
with self._lock:
self._state["connected"] = False
def _has_media(self):
"""True if mpv currently has a file loaded (not idle). Raises
MpvConnectionError if mpv is unreachable."""
return self._mpv.try_get("path") is not None
def _get_property_safe(self, name):
"""Like client.get_property, but treats "property unavailable"
(normal for time-pos/duration/filename while mpv is idle) as None
instead of a fatal error -- only a real MpvConnectionError should
tear down the connection."""
try:
return self._client.get_property(name)
except MpvCommandError:
return None
def _poll_once(self):
# `path` is unavailable while mpv sits idle; try_get returns None then.
# A genuine socket failure raises MpvConnectionError and marks us down.
path = self._mpv.try_get("path") # raises MpvConnectionError if down
idle = bool(self._get_property_safe("idle-active"))
filename = self._get_property_safe("filename")
position = self._get_property_safe("time-pos")
duration = self._get_property_safe("duration")
paused = self._get_property_safe("pause")
volume = self._get_property_safe("volume")
filename = position = duration = None
paused = None
if idle and not self._prev_idle:
self._maybe_advance()
idle = self._get_property_safe("idle-active")
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))
self._prev_idle = bool(idle)
with self._lock:
self._state.update({
@ -102,83 +109,77 @@ class PlayerStateManager:
"duration": duration,
"paused": paused,
"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)
# -- public read API ---------------------------------------------------
def get_status(self):
with self._lock:
return dict(self._state)
# -- helpers -----------------------------------------------------------
def _play_playlist(self, files, start_index):
"""Load `files` into mpv's playlist (folder order) and start at
start_index. mpv advances through them on its own from here on."""
# `loadfile ... replace` starts a fresh playlist with the first file;
# append the rest to rebuild the folder in order, then jump to the
# chosen entry so the playlist matches the folder exactly.
self._mpv.command("loadfile", files[0], "replace")
for f in files[1:]:
self._mpv.command("loadfile", f, "append")
if start_index > 0:
self._mpv.set_property("playlist-pos", start_index)
self._apply_repeat()
def _apply_repeat(self):
"""Set mpv's playlist loop to match keep-playing."""
self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no")
state = dict(self._state)
state["keep_playing"] = self._state["keep_playing"]
return state
# -- public command API (called from Flask routes) ---------------------
def play_file(self, path):
# Queue the whole containing folder so playback continues through the
# rest of it, starting at the chosen file.
self._client.command("loadfile", path, "replace")
# Build the auto-advance queue from the containing folder, positioned
# at the file just started -- so "keep playing" continues through the
# rest of the folder even when you start from the middle.
folder = os.path.dirname(path)
files = list_video_files(folder, self.video_extensions)
try:
index = files.index(path)
except ValueError:
# played file isn't in the folder listing (unusual) -- queue just it
files = [path]
index = 0
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):
files = list_video_files(folder_path, self.video_extensions)
if not files:
raise ValueError(f"no video files in {folder_path}")
self._play_playlist(files, 0)
self._client.command("loadfile", files[0], "replace")
with self._lock:
self._queue_folder = folder_path
self._queue_files = files
self._queue_index = 0
def playpause(self):
if self._has_media():
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")
self._client.command("cycle", "pause")
def seek(self, offset_seconds):
if self._has_media():
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")
self._client.command("seek", offset_seconds, "relative")
def set_volume(self, 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):
with self._lock:
self._keep_playing = bool(enabled)
self._state["keep_playing"] = self._keep_playing
self._apply_repeat()
self._state["keep_playing"] = bool(enabled)

View file

@ -1,7 +1,7 @@
from flask import Blueprint, current_app, jsonify, request
from ..mpv import MpvError
from ..media import PathError, list_directory, resolve_path
from ..mpv_ipc import MpvIPCError
bp = Blueprint("api", __name__, url_prefix="/api")
@ -42,7 +42,7 @@ def play():
player.play_folder(resolved)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except MpvError as exc:
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@ -52,25 +52,7 @@ def play():
def playpause():
try:
current_app.player.playpause()
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:
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@ -85,22 +67,7 @@ def seek():
try:
current_app.player.seek(offset)
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:
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@ -115,7 +82,7 @@ def volume():
try:
current_app.player.set_volume(value)
except MpvError as exc:
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})

View file

@ -135,26 +135,18 @@ html, body {
.transport-row {
display: flex;
gap: 0.4rem;
gap: 0.5rem;
margin: 1rem 0;
}
.transport-row button {
flex: 1;
padding: 0.8rem 0.3rem;
padding: 0.8rem;
border-radius: 6px;
border: 1px solid #444;
background: #1c1c1c;
color: #eee;
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 {

View file

@ -3,9 +3,6 @@
let currentPath = null; // null = top-level media roots
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);
@ -105,23 +102,17 @@
if (!online) return;
el("now-playing").textContent = data.filename || "—";
el("time-pos").textContent = formatTime(data.position);
el("time-dur").textContent = formatTime(data.duration);
const slider = el("seek-slider");
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;
el("time-pos").textContent = formatTime(data.position);
}
slider.value = data.position || 0;
if (document.activeElement !== el("volume-slider")) {
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" });
});
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", () => {
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 }) });
});
// 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) => {
clearTimeout(volumeDebounce);
const value = e.target.value;
@ -168,7 +137,6 @@
});
el("keep-playing-checkbox").addEventListener("change", (e) => {
keepSuppressUntil = Date.now() + 1500;
api("/api/control/keep-playing", {
method: "POST",
body: JSON.stringify({ enabled: e.target.checked }),

View file

@ -27,15 +27,13 @@
<p id="now-playing" class="filename">&mdash;</p>
<div class="progress-row">
<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>
</div>
<div class="transport-row">
<button id="btn-prev" title="Previous clip">&#9198;</button>
<button id="btn-back30">&laquo; 30</button>
<button id="btn-playpause">Play/Pause</button>
<button id="btn-fwd30">30 &raquo;</button>
<button id="btn-next" title="Next clip">&#9197;</button>
</div>
<div class="volume-row">
<span>Vol</span>

78
scripts/mediapi-session.sh Executable file
View file

@ -0,0 +1,78 @@
#!/bin/sh
#
# mediapi X session -- the X client launched by xinit from mediapi-mpv.service.
#
# Mirrors every extra HDMI output onto the first, then runs ONE fullscreen mpv.
#
# Why an X server at all (we used to run mpv straight on DRM/KMS for fast boot):
# DRM "master" is exclusive per card. The Pi 4's two HDMI connectors live on a
# single vc4 card, so two independent mpv processes cannot both modeset -- the
# second gets "Failed to acquire DRM master: Permission denied" and shows
# nothing. A single X server is the one DRM master and drives BOTH connectors;
# `xrandr --same-as` clones the first output's framebuffer onto the others, so
# one mpv appears on every screen. See git history for the full diagnosis.
#
set -u
RUNTIME_DIR="${MEDIAPI_RUNTIME_DIR:-/run/mediapi}"
SOCKET="${MEDIAPI_MPV_SOCKET:-$RUNTIME_DIR/mpv.sock}"
# Always-on car display: never blank or DPMS-off.
xset -dpms 2>/dev/null || true
xset s off 2>/dev/null || true
connected() { xrandr --query 2>/dev/null | awk '/ connected/{print $1}'; }
# Cold-boot race: the second HDMI connector may not be probed as "connected"
# when X starts. Wait for connected outputs to appear, then let the set settle
# so a screen that lights up a beat later is still mirrored (bounded so a
# genuinely single-screen setup doesn't hang the boot).
deadline=$(( $(date +%s) + 30 ))
outs="$(connected)"
while [ -z "$outs" ] && [ "$(date +%s)" -lt "$deadline" ]; do
sleep 1
outs="$(connected)"
done
stable=0
settle_end=$(( $(date +%s) + 8 ))
while [ "$(date +%s)" -lt "$settle_end" ]; do
sleep 1
cur="$(connected)"
if [ "$cur" != "$outs" ]; then
outs="$cur"
stable=0
else
stable=$(( stable + 1 ))
[ "$stable" -ge 2 ] && break
fi
done
primary=""
rest=""
for o in $outs; do
if [ -z "$primary" ]; then primary="$o"; else rest="$rest $o"; fi
done
echo "mediapi-session: connected outputs:$outs (primary=${primary:-none})"
for o in $rest; do
echo "mediapi-session: mirroring $o onto $primary"
xrandr --output "$o" --same-as "$primary" || true
done
# Single mpv, on X (so one DRM master drives every mirrored connector). Carries
# audio and the app's IPC socket; the app talks only to this one socket now.
# hwdec=v4l2m2m-copy uses the Pi's hardware H.264/HEVC decoder (falls back to
# software for codecs it can't handle). Roughly a third of the CPU of software
# decoding 1080p -- important for an always-on player in a warm car. The single
# mpv has the HW decoder to itself now, so there's no contention.
exec mpv \
--idle=yes \
--vo=gpu-next \
--gpu-context=x11egl \
--hwdec=v4l2m2m-copy \
--fullscreen \
--no-terminal \
--keep-open=no \
--input-ipc-server="$SOCKET" \
--ao=alsa

View file

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

View file

@ -1,39 +1,30 @@
[Unit]
Description=mpv video player (KMS/DRM -- the actual player)
# Needs the DRM/HDMI + sound devices present.
After=local-fs.target systemd-udev-settle.service sound.target
Description=mediapi player (X kiosk: one mpv mirrored across all HDMI outputs)
# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist before
# X tries to grab them. Without this a cold-boot race can leave X unable to find
# a screen. (systemd-udev-settle is deprecated but still the simplest reliable
# gate here; the session script additionally waits for connectors to appear.)
After=local-fs.target systemd-udev-settle.service
Wants=systemd-udev-settle.service
# Keep a login getty off the primary display so nothing else claims tty1.
Conflicts=getty@tty1.service
[Service]
Type=simple
User=${MEDIAPI_USER}
# 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
StandardInput=null
# video+render: GPU/DRM access. input: evdev under X. tty: own the VT for X.
SupplementaryGroups=video render input tty
# A controlling tty so the X server can take over VT 7.
TTYPath=/dev/tty7
StandardInput=tty
StandardOutput=journal
StandardError=journal
# --idle keeps mpv running with no file loaded (holds the playlist between clips
# and after the last one), so the service stays up and the app can always reach
# the socket. Playback renders straight on KMS.
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
RuntimeDirectory=mediapi
RuntimeDirectoryMode=0770
# xinit starts the X server on VT 7 and runs the session script as its client.
# The session mirrors the HDMI outputs (single DRM master) and execs one mpv.
# Non-root X is permitted via /etc/X11/Xwrapper.config (installed by install.sh).
ExecStart=/usr/bin/xinit ${PROJECT_DIR}/scripts/mediapi-session.sh -- :0 vt7 -nolisten tcp -keeptty
Restart=on-failure
RestartSec=5
RestartSec=2
[Install]
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