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

View file

@ -110,45 +110,46 @@ Keep values simple (no spaces / shell-special characters).
## Setup (mediapi app) ## Setup (mediapi app)
Playback is done by **Kodi** (`mediapi-kodi` unit), running standalone on Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
GBM/KMS straight on the hardware — the same smooth, hardware-decoded path as player controlled over its JSON IPC socket. mpv renders video to HDMI; the
LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is phone browser only shows 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.
Kodi handles decode, HDMI audio and display; mediapi keeps a background poll of **Dual-HDMI mirroring.** The Pi 4's two HDMI connectors share a single vc4 DRM
Kodi's player state (position/duration/pause/volume) and drives "keep playing" card, and DRM *master* is exclusive per card — so two independent mpv processes
auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and can't both drive a screen (the second gets `Failed to acquire DRM master`).
enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see Instead the `mediapi-mpv` unit runs a minimal **X server** via `xinit`
`scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env` (`scripts/mediapi-session.sh`): X is the one DRM master, `xrandr --same-as`
(`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`. 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
> **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software both screens, decoded once. mpv carries audio and the app's only IPC socket
> (DRM master is exclusive per card; the X-mirror path can't keep up). Drive (`/run/mediapi/mpv.sock`); `--hwdec=v4l2m2m-copy` uses the Pi's hardware H.264/
> both car screens from one HDMI port through an external powered HDMI splitter. 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) ### First-time install (on the Pi)
```bash ```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 # install uv (Python package/venv manager) if not already present
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
# create your .env (see Configuration above) # create your .env (see Configuration above)
cp .env.example .env && $EDITOR .env cp .env.example .env && $EDITOR .env
# deploy the currently-checked-out ref: installs Kodi + Python deps, enables # deploy the currently-checked-out ref: syncs deps, adds the service user to
# Kodi's JSON-RPC web server, configures the AP, installs+starts the services. # the video+render groups (GPU/DRM access for HDMI), configures the AP,
# Run as your normal user -- NOT with sudo, or the services get rendered to run # installs+starts the services. Run as your normal user -- NOT with sudo, or
# as root. # the services get rendered to run as root.
./install.sh ./install.sh
# verify # verify
systemctl status mediapi-kodi mediapi-app systemctl status mediapi-mpv mediapi-app
curl -s "http://kodi:$(grep KODI_PASSWORD .env|cut -d= -f2)@127.0.0.1:8090/jsonrpc" \ ls -l /run/mediapi/mpv.sock
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' # -> {"result":"pong"}
``` ```
Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
@ -192,20 +193,25 @@ cd ~/mediapi && ./install.sh v1.2.0
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
``` ```
Notes / things to double check on the actual hardware: Notes / things to double check on the actual hardware (couldn't be verified
from a dev machine):
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt` * `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
on current Pi4 images (needed for KMS output) — worth a quick check. on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
* Kodi runs standalone on GBM as the `mediapi-kodi` service (on `tty1`). If the * Mirroring clones every HDMI output that reads `connected` at session start
screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's onto the first. The session waits for connectors to probe (cold-boot race),
own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the then runs `xrandr --same-as`. See what it did with
`video render input audio tty` groups (install.sh adds them). `sudo journalctl -u mediapi-mpv | grep mediapi-session`; inspect the outputs
* If the web API is unreachable (`Connection refused` on live with `DISPLAY=:0 xrandr` (as the service user). Both screens should share
`:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites a common resolution; if they differ, `xrandr` clones at the first output's
`guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the mode. Confirm the one socket: `ls -l /run/mediapi/mpv.sock`.
setting via `scripts/configure-kodi.py`, and restarts), or toggle * Non-root X requires `/etc/X11/Xwrapper.config` with `allowed_users=anybody`
Settings → Services → Control → *Allow remote control via HTTP* in the Kodi (install.sh writes it). If the service crash-loops with `Could not create
GUI once. Verify with `JSONRPC.Ping` (see install snippet above). server lock file: /tmp/.X0-lock`, a previous X died uncleanly — remove
* Kodi handles HDMI audio itself (Settings → System → Audio). If there's no `/tmp/.X0-lock` (and `/tmp/.X11-unix/X0`) and restart. The X log is at
sound, set the audio output device to the HDMI sink there. `~/.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` To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`

View file

@ -41,12 +41,9 @@ DEPLOYED_REF_FILE="instance/deployed_ref"
TARGET_REF="${1:-}" TARGET_REF="${1:-}"
# --- load config ---------------------------------------------------- # --- load config ----------------------------------------------------
# First run on a fresh Pi may have no .env yet -- seed it from the example so the
# script can proceed, but make it loud: the defaults ship an insecure AP.
if [[ ! -f .env ]]; then if [[ ! -f .env ]]; then
echo "WARNING: .env not found -- creating it from .env.example." >&2 echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2
echo " Edit .env with your real AP/login passwords, then re-run." >&2 exit 1
cp .env.example .env
fi fi
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@ -55,7 +52,7 @@ set +a
MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}" MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}"
MEDIAPI_PORT="${MEDIAPI_PORT:-8080}" MEDIAPI_PORT="${MEDIAPI_PORT:-8080}"
UV="" # set by bootstrap_system once uv is installed UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
# --- 0. refuse to run on a read-only overlay ------------------------ # --- 0. refuse to run on a read-only overlay ------------------------
if findmnt -no FSTYPE / | grep -q overlay; then if findmnt -no FSTYPE / | grep -q overlay; then
@ -93,30 +90,9 @@ if [[ -n "$PREV_GOOD" && "$PREV_GOOD" != "$CURRENT_REF" ]]; then
echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)" echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)"
fi fi
# --- system bootstrap (fresh Pi: assume ONLY git is installed) -------
# Installs everything else the deploy needs so a bare Raspberry Pi OS Lite goes
# from "git clone + ./install.sh" to a working player. NetworkManager (nmcli)
# and raspi-config already ship on Pi OS. Sets the global UV path afterwards.
bootstrap_system() {
echo "==> apt update + base packages (curl, python3) ..."
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl ca-certificates python3
if ! command -v uv >/dev/null 2>&1 && [[ ! -x "$HOME/.local/bin/uv" ]]; then
echo "==> Installing uv ..."
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
if [[ ! -x "$UV" ]]; then
echo "ERROR: uv is still not available at '$UV' after install." >&2
exit 1
fi
}
# --- render + install systemd units (used again on rollback) -------- # --- render + install systemd units (used again on rollback) --------
install_units() { install_units() {
for unit in mediapi-mpv mediapi-app mediapi-watchdog; do for unit in mediapi-mpv mediapi-app; do
sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \ sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \
-e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \ -e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \
-e "s|\${UV}|${UV}|g" \ -e "s|\${UV}|${UV}|g" \
@ -140,29 +116,27 @@ deploy_current() {
chmod 600 instance/secret_key chmod 600 instance/secret_key
fi fi
# mpv (the player) runs as MEDIAPI_USER on KMS/DRM and needs these groups to # The mpv service runs as MEDIAPI_USER and needs the video+render groups to
# reach the GPU/DRM, audio, input and console devices. Idempotent; systemd # reach the GPU/DRM devices for HDMI output, plus tty+input to own the VT and
# picks up the new membership when it (re)starts the service below. # 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 if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then
echo "==> Adding '${MEDIAPI_USER}' to video,render,input,audio,tty groups ..." echo "==> Adding '${MEDIAPI_USER}' to video,render,input,tty groups ..."
sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}" sudo usermod -aG video,render,input,tty "${MEDIAPI_USER}"
fi fi
# Install mpv (the actual media player -- mediapi drives it via its IPC socket). # The player runs a minimal X server so a single mpv can be mirrored across
if ! command -v mpv >/dev/null 2>&1; then # both HDMI outputs (one DRM master; two separate mpv on the shared vc4 card
echo "==> Installing mpv ..." # cannot -- see scripts/mediapi-session.sh). Install just the X server + xinit
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mpv # + 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 fi
echo "==> Configuring /etc/X11/Xwrapper.config (allow non-root X on the VT) ..."
# Ensure the media browse roots exist so browsing works and there's a place to printf 'allowed_users=anybody\nneeds_root_rights=yes\n' \
# copy content into. (mkdir -p is a no-op if it's already a mount point.) | sudo tee /etc/X11/Xwrapper.config >/dev/null
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}"
@ -182,38 +156,11 @@ deploy_current() {
ipv4.method shared ipv4.method shared
fi fi
echo "==> Installing systemd units ..." echo "==> Installing systemd units + restarting services ..."
install_units install_units
sudo systemctl enable mediapi-mpv mediapi-app mediapi-watchdog >/dev/null 2>&1 || true sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true
# Migration cleanup: earlier versions ran Kodi as the player. A still-running
# Kodi keeps the GPU's DRM master and the tty1 seat, which stops mpv from ever
# acquiring the display -- and removing a unit file does NOT stop an already
# running process. So explicitly tear any Kodi down before starting mpv.
if [[ -e /etc/systemd/system/mediapi-kodi.service ]] || pgrep -x kodi.bin >/dev/null 2>&1; then
echo "==> Removing leftover Kodi player ..."
sudo systemctl unmask mediapi-kodi.service 2>/dev/null || true
sudo systemctl disable --now mediapi-kodi.service 2>/dev/null || true
sudo rm -f /etc/systemd/system/mediapi-kodi.service
sudo pkill -9 -x kodi.bin 2>/dev/null || true
sudo pkill -9 -f kodi-standalone 2>/dev/null || true
sudo systemctl daemon-reload
fi
echo "==> Starting services ..."
sudo systemctl restart mediapi-mpv sudo systemctl restart mediapi-mpv
sudo systemctl restart mediapi-app sudo systemctl restart mediapi-app
sudo systemctl restart mediapi-watchdog
# Wait for mpv's IPC socket so a first-run deploy leaves a driveable player.
echo "==> Waiting for mpv IPC socket (${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}) ..."
for _ in $(seq 1 30); do
if sudo test -S "${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"; then
echo " mpv is up."
break
fi
sleep 1
done
} }
# --- health check --------------------------------------------------- # --- health check ---------------------------------------------------
@ -228,7 +175,6 @@ app_healthy() {
} }
# --- 2-6. deploy ---------------------------------------------------- # --- 2-6. deploy ----------------------------------------------------
bootstrap_system
deploy_current deploy_current
# --- 7. health check + rollback ------------------------------------ # --- 7. health check + rollback ------------------------------------
@ -236,7 +182,7 @@ echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..."
if app_healthy; then if app_healthy; then
echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE" echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE"
echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}." echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}."
systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app mediapi-watchdog || true systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app || true
exit 0 exit 0
fi fi

View file

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

View file

@ -75,9 +75,8 @@ 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 # A single mpv drives every HDMI output -- the X session mirrors the screens
# as its own systemd service with --input-ipc-server pointed at this path # with xrandr (see scripts/mediapi-session.sh) -- so there is one socket.
# (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")
PORT = int(os.environ.get("MEDIAPI_PORT", "8080")) 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 import time
from .media import list_video_files from .media import list_video_files
from .mpv import MpvConnectionError, MpvError from .mpv_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -13,28 +13,21 @@ RECONNECT_INTERVAL = 2.0
class PlayerStateManager: class PlayerStateManager:
"""Controls mpv over its JSON IPC socket and caches its playback state. """Owns the single connection to mpv's IPC socket. A background thread
polls mpv for playback state and drives "keep playing" auto-advance;
Flask request handlers only ever read the cached snapshot or send a
command through this class -- they never touch the socket directly."""
Playback is driven through mpv's own PLAYLIST: playing a file or folder def __init__(self, socket_path, video_extensions):
loads the whole folder into mpv's playlist and jumps to the chosen entry. self.socket_path = socket_path
mpv then advances through the folder (and loops it, when "keep playing" is
on) ALL BY ITSELF -- so playback keeps going even if this app, the phone, or
the WiFi disconnect. mediapi only issues commands and polls state; it is
never in the playback loop. Next/Previous are mpv playlist navigation; "keep
playing" is mpv's `loop-playlist`.
"""
def __init__(self, mpv_client, video_extensions):
self._mpv = mpv_client
self.video_extensions = video_extensions self.video_extensions = video_extensions
# 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._lock = threading.Lock()
self._stop = threading.Event() self._stop = threading.Event()
# Desired repeat state. Default ON: a lean-back player (kids' videos in a
# car) should keep running through/looping the folder, not stop.
self._keep_playing = True
self._state = { self._state = {
"connected": False, "connected": False,
"filename": None, "filename": None,
@ -42,9 +35,14 @@ class PlayerStateManager:
"duration": None, "duration": None,
"paused": None, "paused": None,
"volume": None, "volume": None,
"keep_playing": True, "keep_playing": False,
} }
self._queue_folder = None
self._queue_files = []
self._queue_index = -1
self._prev_idle = True
def start(self): def start(self):
thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True) thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True)
thread.start() thread.start()
@ -52,47 +50,56 @@ class PlayerStateManager:
def stop(self): def stop(self):
self._stop.set() self._stop.set()
# -- background loop (status only; mpv owns auto-advance) -------------- # -- background loop -------------------------------------------------
def _run(self): def _run(self):
while not self._stop.is_set(): while not self._stop.is_set():
if not self._client.connected:
try:
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.debug("mpv not reachable: %s", exc) log.warning("lost connection to mpv: %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 _has_media(self): def _get_property_safe(self, name):
"""True if mpv currently has a file loaded (not idle). Raises """Like client.get_property, but treats "property unavailable"
MpvConnectionError if mpv is unreachable.""" (normal for time-pos/duration/filename while mpv is idle) as None
return self._mpv.try_get("path") is not None instead of a fatal error -- only a real MpvConnectionError should
tear down the connection."""
try:
return self._client.get_property(name)
except MpvCommandError:
return None
def _poll_once(self): def _poll_once(self):
# `path` is unavailable while mpv sits idle; try_get returns None then. idle = bool(self._get_property_safe("idle-active"))
# A genuine socket failure raises MpvConnectionError and marks us down. filename = self._get_property_safe("filename")
path = self._mpv.try_get("path") # raises MpvConnectionError if down position = self._get_property_safe("time-pos")
duration = self._get_property_safe("duration")
paused = self._get_property_safe("pause")
volume = self._get_property_safe("volume")
filename = position = duration = None if idle and not self._prev_idle:
paused = None self._maybe_advance()
idle = self._get_property_safe("idle-active")
if path is not None: self._prev_idle = bool(idle)
filename = os.path.basename(path) or self._mpv.try_get("media-title")
position = self._mpv.try_get("time-pos")
duration = self._mpv.try_get("duration")
paused = bool(self._mpv.try_get("pause", False))
volume = self._mpv.try_get("volume")
if volume is not None:
volume = int(round(volume))
with self._lock: with self._lock:
self._state.update({ self._state.update({
@ -102,83 +109,77 @@ 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)
# -- public read API --------------------------------------------------- # -- public read API ---------------------------------------------------
def get_status(self): def get_status(self):
with self._lock: with self._lock:
return dict(self._state) state = dict(self._state)
state["keep_playing"] = self._state["keep_playing"]
# -- helpers ----------------------------------------------------------- return state
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")
# -- public command API (called from Flask routes) --------------------- # -- public command API (called from Flask routes) ---------------------
def play_file(self, path): def play_file(self, path):
# Queue the whole containing folder so playback continues through the self._client.command("loadfile", path, "replace")
# rest of it, starting at the chosen file. # Build the auto-advance queue from the containing folder, positioned
# at the file just started -- so "keep playing" continues through the
# rest of the folder even when you start from the middle.
folder = os.path.dirname(path) folder = os.path.dirname(path)
files = list_video_files(folder, self.video_extensions) files = list_video_files(folder, self.video_extensions)
try: try:
index = files.index(path) index = files.index(path)
except ValueError: except ValueError:
# played file isn't in the folder listing (unusual) -- queue just it
files = [path] files = [path]
index = 0 index = 0
self._play_playlist(files, index) with self._lock:
self._queue_folder = folder
self._queue_files = files
self._queue_index = index
def play_folder(self, folder_path): def play_folder(self, folder_path):
files = list_video_files(folder_path, self.video_extensions) files = list_video_files(folder_path, self.video_extensions)
if not files: if not files:
raise ValueError(f"no video files in {folder_path}") raise ValueError(f"no video files in {folder_path}")
self._play_playlist(files, 0) self._client.command("loadfile", files[0], "replace")
with self._lock:
self._queue_folder = folder_path
self._queue_files = files
self._queue_index = 0
def playpause(self): def playpause(self):
if self._has_media(): self._client.command("cycle", "pause")
self._mpv.command("cycle", "pause")
def next(self):
if self._has_media():
self._mpv.command("playlist-next", "weak")
def previous(self):
if self._has_media():
self._mpv.command("playlist-prev", "weak")
def seek(self, offset_seconds): def seek(self, offset_seconds):
if self._has_media(): self._client.command("seek", offset_seconds, "relative")
self._mpv.command("seek", int(offset_seconds), "relative")
def seek_to(self, position_seconds):
"""Seek to an absolute position (seconds from the start) -- used by the
draggable progress bar."""
if self._has_media():
pos = max(0, int(position_seconds))
self._mpv.command("seek", pos, "absolute")
def set_volume(self, value): def set_volume(self, value):
value = max(0, min(100, value)) value = max(0, min(100, value))
self._mpv.set_property("volume", value) self._client.set_property("volume", value)
def set_keep_playing(self, enabled): def set_keep_playing(self, enabled):
with self._lock: with self._lock:
self._keep_playing = bool(enabled) self._state["keep_playing"] = bool(enabled)
self._state["keep_playing"] = self._keep_playing
self._apply_repeat()

View file

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

View file

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

View file

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

View file

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

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] [Unit]
Description=mpv video player (KMS/DRM -- the actual player) Description=mediapi player (X kiosk: one mpv mirrored across all HDMI outputs)
# Needs the DRM/HDMI + sound devices present. # Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist before
After=local-fs.target systemd-udev-settle.service sound.target # 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 Wants=systemd-udev-settle.service
# Keep a login getty off the primary display so nothing else claims tty1.
Conflicts=getty@tty1.service
[Service] [Service]
Type=simple
User=${MEDIAPI_USER} User=${MEDIAPI_USER}
# mpv on KMS/DRM needs: video+render (GPU/DRM), input (remotes/keyboard), audio # video+render: GPU/DRM access. input: evdev under X. tty: own the VT for X.
# (HDMI/ALSA). It becomes DRM master simply as the first/only DRM client on the SupplementaryGroups=video render input tty
# seat -- no PAM login session and no elevated capabilities required, provided # A controlling tty so the X server can take over VT 7.
# nothing else is already holding the GPU. (An older Kodi-based deploy DID hold TTYPath=/dev/tty7
# it; install.sh tears any leftover Kodi down before starting this.) StandardInput=tty
SupplementaryGroups=video render input audio
# Create /run/mediapi (owned by this user) for the IPC socket the app connects to.
RuntimeDirectory=mediapi
StandardInput=null
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
# --idle keeps mpv running with no file loaded (holds the playlist between clips RuntimeDirectory=mediapi
# and after the last one), so the service stays up and the app can always reach RuntimeDirectoryMode=0770
# the socket. Playback renders straight on KMS. # xinit starts the X server on VT 7 and runs the session script as its client.
ExecStart=/usr/bin/mpv \ # The session mirrors the HDMI outputs (single DRM master) and execs one mpv.
--idle=yes \ # Non-root X is permitted via /etc/X11/Xwrapper.config (installed by install.sh).
--input-ipc-server=/run/mediapi/mpv.sock \ ExecStart=/usr/bin/xinit ${PROJECT_DIR}/scripts/mediapi-session.sh -- :0 vt7 -nolisten tcp -keeptty
--no-terminal \
--no-input-default-bindings \
--fullscreen \
--force-window=yes \
--keep-open=no \
--vo=gpu --gpu-context=drm --hwdec=auto \
--ao=alsa --audio-device=alsa/hdmi:CARD=vc4hdmi0,DEV=0
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=2
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

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