Drive both HDMI outputs via X kiosk + single mirrored mpv
The Pi 4's two HDMI connectors share one vc4 DRM card, and DRM master is exclusive per card -- so the old per-connector-mpv design could never light the second screen (the mirror mpv died with "Failed to acquire DRM master: Permission denied"). Replace it with a minimal X server started by the mediapi-mpv unit via xinit: X is the single DRM master, xrandr --same-as clones the first output onto every other connected HDMI, and one fullscreen mpv renders to both. Uses hwdec=v4l2m2m-copy (Pi HW decoder, ~1/3 the CPU of software) and carries audio + the app's sole IPC socket. - new scripts/mediapi-session.sh (X client: waits for connectors, mirrors, execs mpv); rewritten mediapi-mpv unit (xinit on VT7) - delete scripts/start-mpv.py and all mirror-socket/broadcast code in player.py, config.py, __init__.py -- a single mpv means one socket - install.sh installs xserver-xorg-core/xinit/x11-xserver-utils, writes /etc/X11/Xwrapper.config, adds the user to input,tty - README + troubleshooting updated Verified from a cold boot on the Pi: both pixelvalve CRTCs scan the same framebuffer; app healthy; HW decode engaged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8ae4b40263
commit
ee72d8b735
8 changed files with 146 additions and 255 deletions
50
README.md
50
README.md
|
|
@ -111,26 +111,27 @@ Keep values simple (no spaces / shell-special characters).
|
||||||
## Setup (mediapi app)
|
## Setup (mediapi app)
|
||||||
|
|
||||||
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
|
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
|
||||||
player controlled over its JSON IPC socket. mpv renders video to HDMI
|
player controlled over its JSON IPC socket. mpv renders video to HDMI; the
|
||||||
directly (DRM/KMS, no desktop needed); the phone browser only shows
|
phone browser only shows metadata/controls, never the video image itself.
|
||||||
metadata/controls, never the video image itself.
|
|
||||||
|
|
||||||
**Dual-HDMI mirroring.** A single mpv on DRM can only drive one connector, and
|
**Dual-HDMI mirroring.** The Pi 4's two HDMI connectors share a single vc4 DRM
|
||||||
the Pi's `vc4-kms` driver can't clone two HDMI outputs onto one framebuffer —
|
card, and DRM *master* is exclusive per card — so two independent mpv processes
|
||||||
so mirroring is done by running *one mpv per connected screen*.
|
can't both drive a screen (the second gets `Failed to acquire DRM master`).
|
||||||
`scripts/start-mpv.py` (launched by the `mediapi-mpv` unit) enumerates the
|
Instead the `mediapi-mpv` unit runs a minimal **X server** via `xinit`
|
||||||
connected HDMI connectors and starts a **primary** mpv (audio + the app's IPC
|
(`scripts/mediapi-session.sh`): X is the one DRM master, `xrandr --same-as`
|
||||||
socket, `mpv.sock`) plus a **mirror** mpv per extra screen (video-only, on
|
clones the first output's framebuffer onto every other connected HDMI output,
|
||||||
`mpv-mirror-<connector>.sock`). The app plays/pauses/seeks the primary and
|
and a **single** fullscreen mpv renders into it — so the same frames appear on
|
||||||
echoes those to the mirrors best-effort, re-syncing on every video. One screen
|
both screens, decoded once. mpv carries audio and the app's only IPC socket
|
||||||
→ same as before; a dead mirror just leaves that screen dark, never the audio
|
(`/run/mediapi/mpv.sock`); `--hwdec=v4l2m2m-copy` uses the Pi's hardware H.264/
|
||||||
screen. Because each screen runs its own mpv, the same file decodes once per
|
HEVC decoder (~⅓ the CPU of software decoding), falling back to software for
|
||||||
screen (`--hwdec=auto-safe` falls back to software if the HW decoder is busy).
|
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 HDMI/DRM playback
|
# system deps: mpv for playback (install.sh installs the minimal X server itself)
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y mpv
|
sudo apt install -y mpv
|
||||||
|
|
||||||
|
|
@ -196,13 +197,18 @@ Notes / things to double check on the actual hardware (couldn't be verified
|
||||||
from a dev machine):
|
from a dev machine):
|
||||||
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
|
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
|
||||||
on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
|
on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
|
||||||
* Mirroring picks up whatever HDMI connectors read `connected` under
|
* Mirroring clones every HDMI output that reads `connected` at session start
|
||||||
`/sys/class/drm/card*-HDMI-*/status` at service start — so plug in both
|
onto the first. The session waits for connectors to probe (cold-boot race),
|
||||||
screens **before** `mediapi-mpv` starts (or `sudo systemctl restart
|
then runs `xrandr --same-as`. See what it did with
|
||||||
mediapi-mpv` after). Check what it launched with
|
`sudo journalctl -u mediapi-mpv | grep mediapi-session`; inspect the outputs
|
||||||
`sudo journalctl -u mediapi-mpv | grep start-mpv` and confirm both sockets:
|
live with `DISPLAY=:0 xrandr` (as the service user). Both screens should share
|
||||||
`ls -l /run/mediapi/mpv*.sock`. `mpv --drm-connector=help` (with a display
|
a common resolution; if they differ, `xrandr` clones at the first output's
|
||||||
attached) lists the connector names if the sysfs guess is ever wrong.
|
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
|
* 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.
|
device name (usually `vc4-hdmi`) and add e.g.
|
||||||
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
|
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
|
||||||
|
|
|
||||||
22
install.sh
22
install.sh
|
|
@ -117,13 +117,27 @@ deploy_current() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The mpv service runs as MEDIAPI_USER and needs the video+render groups to
|
# The mpv service runs as MEDIAPI_USER and needs the video+render groups to
|
||||||
# reach the GPU/DRM devices for HDMI output. Idempotent; systemd picks up the
|
# reach the GPU/DRM devices for HDMI output, plus tty+input to own the VT and
|
||||||
# new membership when it (re)starts the service below, so no logout needed.
|
# 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 groups ..."
|
echo "==> Adding '${MEDIAPI_USER}' to video,render,input,tty groups ..."
|
||||||
sudo usermod -aG video,render "${MEDIAPI_USER}"
|
sudo usermod -aG video,render,input,tty "${MEDIAPI_USER}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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 ..."
|
echo "==> Applying WiFi country + AP config ..."
|
||||||
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
|
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
|
||||||
if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then
|
if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ def create_app():
|
||||||
app.player = PlayerStateManager(
|
app.player = PlayerStateManager(
|
||||||
app.config["MPV_SOCKET"],
|
app.config["MPV_SOCKET"],
|
||||||
app.config["VIDEO_EXTENSIONS"],
|
app.config["VIDEO_EXTENSIONS"],
|
||||||
mirror_glob=app.config.get("MPV_MIRROR_GLOB"),
|
|
||||||
)
|
)
|
||||||
app.player.start()
|
app.player.start()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,9 @@ 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
|
||||||
]
|
]
|
||||||
|
# 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")
|
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
|
||||||
# Extra mpv instances (one per additional HDMI screen) expose sockets named
|
|
||||||
# mpv-mirror-<connector>.sock alongside the primary socket. The player
|
|
||||||
# discovers them by glob and echoes playback commands to them best-effort.
|
|
||||||
MPV_MIRROR_GLOB = os.path.join(os.path.dirname(MPV_SOCKET), "mpv-mirror-*.sock")
|
|
||||||
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
|
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
|
||||||
|
|
||||||
VIDEO_EXTENSIONS = {
|
VIDEO_EXTENSIONS = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import glob
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -19,17 +18,12 @@ class PlayerStateManager:
|
||||||
Flask request handlers only ever read the cached snapshot or send a
|
Flask request handlers only ever read the cached snapshot or send a
|
||||||
command through this class -- they never touch the socket directly."""
|
command through this class -- they never touch the socket directly."""
|
||||||
|
|
||||||
def __init__(self, socket_path, video_extensions, mirror_glob=None):
|
def __init__(self, socket_path, video_extensions):
|
||||||
self.socket_path = socket_path
|
self.socket_path = socket_path
|
||||||
self.video_extensions = video_extensions
|
self.video_extensions = video_extensions
|
||||||
# Sockets of any per-screen "mirror" mpv instances (see start-mpv.py).
|
# A single mpv drives every HDMI output (the X session mirrors the
|
||||||
# We only ever push playback commands to these best-effort; the primary
|
# screens with xrandr -- see scripts/mediapi-session.sh), so there is
|
||||||
# socket above is the sole source of state and the one that carries
|
# exactly one socket to talk to.
|
||||||
# audio, so a missing/broken mirror just means one screen is dark.
|
|
||||||
self._mirror_glob = mirror_glob
|
|
||||||
self._mirror_clients = {}
|
|
||||||
self._mirror_lock = threading.Lock()
|
|
||||||
|
|
||||||
self._client = MpvIPCClient(socket_path)
|
self._client = MpvIPCClient(socket_path)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
|
|
@ -137,7 +131,6 @@ class PlayerStateManager:
|
||||||
self._client.command("loadfile", next_file, "replace")
|
self._client.command("loadfile", next_file, "replace")
|
||||||
except MpvIPCError as exc:
|
except MpvIPCError as exc:
|
||||||
log.warning("auto-advance loadfile failed: %s", exc)
|
log.warning("auto-advance loadfile failed: %s", exc)
|
||||||
self._broadcast("loadfile", next_file, "replace")
|
|
||||||
|
|
||||||
# -- public read API ---------------------------------------------------
|
# -- public read API ---------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -147,37 +140,10 @@ class PlayerStateManager:
|
||||||
state["keep_playing"] = self._state["keep_playing"]
|
state["keep_playing"] = self._state["keep_playing"]
|
||||||
return state
|
return state
|
||||||
|
|
||||||
# -- mirror screens (best-effort) --------------------------------------
|
|
||||||
|
|
||||||
def _broadcast(self, *command_args):
|
|
||||||
"""Echo a playback command to every mirror mpv, best-effort. Never
|
|
||||||
raises: a mirror that's absent, mid-restart, or wedged must not affect
|
|
||||||
the primary screen or the HTTP response. Mirrors are re-synced on every
|
|
||||||
loadfile, so a command missed here self-heals at the next video."""
|
|
||||||
if not self._mirror_glob:
|
|
||||||
return
|
|
||||||
with self._mirror_lock:
|
|
||||||
paths = set(glob.glob(self._mirror_glob))
|
|
||||||
# Drop clients whose socket disappeared (display unplugged).
|
|
||||||
for gone in set(self._mirror_clients) - paths:
|
|
||||||
self._mirror_clients.pop(gone).close()
|
|
||||||
for path in paths:
|
|
||||||
client = self._mirror_clients.get(path)
|
|
||||||
if client is None:
|
|
||||||
client = self._mirror_clients[path] = MpvIPCClient(path)
|
|
||||||
try:
|
|
||||||
if not client.connected:
|
|
||||||
client.connect()
|
|
||||||
client.command(*command_args)
|
|
||||||
except (OSError, MpvIPCError) as exc:
|
|
||||||
client.close()
|
|
||||||
log.debug("mirror %s command failed: %s", path, exc)
|
|
||||||
|
|
||||||
# -- public command API (called from Flask routes) ---------------------
|
# -- public command API (called from Flask routes) ---------------------
|
||||||
|
|
||||||
def play_file(self, path):
|
def play_file(self, path):
|
||||||
self._client.command("loadfile", path, "replace")
|
self._client.command("loadfile", path, "replace")
|
||||||
self._broadcast("loadfile", path, "replace")
|
|
||||||
# Build the auto-advance queue from the containing folder, positioned
|
# Build the auto-advance queue from the containing folder, positioned
|
||||||
# at the file just started -- so "keep playing" continues through the
|
# at the file just started -- so "keep playing" continues through the
|
||||||
# rest of the folder even when you start from the middle.
|
# rest of the folder even when you start from the middle.
|
||||||
|
|
@ -199,7 +165,6 @@ class PlayerStateManager:
|
||||||
if not files:
|
if not files:
|
||||||
raise ValueError(f"no video files in {folder_path}")
|
raise ValueError(f"no video files in {folder_path}")
|
||||||
self._client.command("loadfile", files[0], "replace")
|
self._client.command("loadfile", files[0], "replace")
|
||||||
self._broadcast("loadfile", files[0], "replace")
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._queue_folder = folder_path
|
self._queue_folder = folder_path
|
||||||
self._queue_files = files
|
self._queue_files = files
|
||||||
|
|
@ -207,11 +172,9 @@ class PlayerStateManager:
|
||||||
|
|
||||||
def playpause(self):
|
def playpause(self):
|
||||||
self._client.command("cycle", "pause")
|
self._client.command("cycle", "pause")
|
||||||
self._broadcast("cycle", "pause")
|
|
||||||
|
|
||||||
def seek(self, offset_seconds):
|
def seek(self, offset_seconds):
|
||||||
self._client.command("seek", offset_seconds, "relative")
|
self._client.command("seek", offset_seconds, "relative")
|
||||||
self._broadcast("seek", offset_seconds, "relative")
|
|
||||||
|
|
||||||
def set_volume(self, value):
|
def set_volume(self, value):
|
||||||
value = max(0, min(100, value))
|
value = max(0, min(100, value))
|
||||||
|
|
|
||||||
78
scripts/mediapi-session.sh
Executable file
78
scripts/mediapi-session.sh
Executable 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
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Launch one mpv per connected HDMI output so playback mirrors across every
|
|
||||||
attached display.
|
|
||||||
|
|
||||||
mpv's DRM output can only ever drive a single connector, and the Pi's vc4-kms
|
|
||||||
driver can't clone two HDMI connectors onto one framebuffer -- so "mirror on
|
|
||||||
both screens" means running one mpv per connector. This script enumerates the
|
|
||||||
connected connectors at start and launches:
|
|
||||||
|
|
||||||
* a PRIMARY mpv on the first connected connector -- owns audio and the IPC
|
|
||||||
socket the Flask app talks to (/run/mediapi/mpv.sock);
|
|
||||||
* a MIRROR mpv on each additional connector -- video only (--ao=null), each
|
|
||||||
on its own IPC socket (/run/mediapi/mpv-mirror-<connector>.sock) so the app
|
|
||||||
can echo loadfile/pause/seek to it best-effort.
|
|
||||||
|
|
||||||
Design choices, all in service of "must work unattended in a car":
|
|
||||||
|
|
||||||
* If no connector can be detected (unexpected sysfs naming, etc.) it falls
|
|
||||||
back to a single auto-picking mpv -- i.e. exactly the old behavior, never
|
|
||||||
worse.
|
|
||||||
* It supervises the children: if any mpv exits, it tears the rest down and
|
|
||||||
exits non-zero so systemd restarts the whole unit (which re-enumerates
|
|
||||||
displays -- e.g. a screen powered on since last start).
|
|
||||||
* The mirror is strictly best-effort; the primary is what carries audio and
|
|
||||||
control, so a dead mirror degrades to single-screen playback.
|
|
||||||
|
|
||||||
Runs as a plain stdlib script (no venv needed) so it can start early at boot.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
RUNTIME_DIR = os.environ.get("MEDIAPI_RUNTIME_DIR", "/run/mediapi")
|
|
||||||
PRIMARY_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", os.path.join(RUNTIME_DIR, "mpv.sock"))
|
|
||||||
|
|
||||||
# Flags shared by every instance. Kept in sync with what the mpv systemd unit
|
|
||||||
# used to pass directly.
|
|
||||||
COMMON_ARGS = [
|
|
||||||
"/usr/bin/mpv",
|
|
||||||
"--idle=yes",
|
|
||||||
"--vo=gpu-next",
|
|
||||||
"--gpu-context=drm",
|
|
||||||
"--hwdec=auto-safe", # falls back to software if the HW decoder is busy
|
|
||||||
"--fullscreen",
|
|
||||||
"--no-terminal",
|
|
||||||
"--keep-open=no",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def connected_hdmi_outputs():
|
|
||||||
"""Return [(card_device, connector_name), ...] for every connected HDMI
|
|
||||||
connector, e.g. [("/dev/dri/card1", "HDMI-A-1"), ...], sorted by connector
|
|
||||||
name so the assignment of primary/mirror is stable across restarts."""
|
|
||||||
outputs = []
|
|
||||||
for status_path in sorted(glob.glob("/sys/class/drm/card*-HDMI-*/status")):
|
|
||||||
try:
|
|
||||||
with open(status_path) as f:
|
|
||||||
status = f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if status != "connected":
|
|
||||||
continue
|
|
||||||
# .../card1-HDMI-A-1/status -> card="card1", connector="HDMI-A-1"
|
|
||||||
m = re.match(r"(card\d+)-(HDMI-\S+)", os.path.basename(os.path.dirname(status_path)))
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
card, connector = m.group(1), m.group(2)
|
|
||||||
outputs.append((f"/dev/dri/{card}", connector))
|
|
||||||
return outputs
|
|
||||||
|
|
||||||
|
|
||||||
def mirror_socket_path(connector):
|
|
||||||
return os.path.join(os.path.dirname(PRIMARY_SOCKET), f"mpv-mirror-{connector}.sock")
|
|
||||||
|
|
||||||
|
|
||||||
def build_commands():
|
|
||||||
"""Build the list of mpv argv lists to launch."""
|
|
||||||
outputs = connected_hdmi_outputs()
|
|
||||||
|
|
||||||
if not outputs:
|
|
||||||
# Couldn't identify any connector -- fall back to a single auto-picking
|
|
||||||
# mpv (the old behavior). Never worse than before.
|
|
||||||
print("start-mpv: no HDMI connector detected, launching single auto mpv", flush=True)
|
|
||||||
return [COMMON_ARGS + [f"--input-ipc-server={PRIMARY_SOCKET}", "--ao=alsa"]]
|
|
||||||
|
|
||||||
commands = []
|
|
||||||
primary_card, primary_connector = outputs[0]
|
|
||||||
print(f"start-mpv: primary on {primary_connector} ({primary_card})", flush=True)
|
|
||||||
commands.append(
|
|
||||||
COMMON_ARGS
|
|
||||||
+ [
|
|
||||||
f"--drm-device={primary_card}",
|
|
||||||
f"--drm-connector={primary_connector}",
|
|
||||||
f"--input-ipc-server={PRIMARY_SOCKET}",
|
|
||||||
"--ao=alsa",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
for card, connector in outputs[1:]:
|
|
||||||
sock = mirror_socket_path(connector)
|
|
||||||
print(f"start-mpv: mirror on {connector} ({card}) -> {sock}", flush=True)
|
|
||||||
commands.append(
|
|
||||||
COMMON_ARGS
|
|
||||||
+ [
|
|
||||||
f"--drm-device={card}",
|
|
||||||
f"--drm-connector={connector}",
|
|
||||||
f"--input-ipc-server={sock}",
|
|
||||||
"--ao=null",
|
|
||||||
"--mute=yes",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return commands
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Clean up any stale mirror sockets from a previous run so the app doesn't
|
|
||||||
# try to talk to a connector that's no longer attached.
|
|
||||||
for stale in glob.glob(os.path.join(os.path.dirname(PRIMARY_SOCKET), "mpv-mirror-*.sock")):
|
|
||||||
try:
|
|
||||||
os.unlink(stale)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
procs = [subprocess.Popen(cmd) for cmd in build_commands()]
|
|
||||||
|
|
||||||
stopping = {"flag": False}
|
|
||||||
|
|
||||||
def kill_children():
|
|
||||||
for p in procs:
|
|
||||||
if p.poll() is None:
|
|
||||||
p.terminate()
|
|
||||||
|
|
||||||
def on_signal(*_):
|
|
||||||
# Told to stop (systemctl stop / Ctrl-C): kill the children and let
|
|
||||||
# main exit 0 so systemd treats it as a clean stop, not a crash.
|
|
||||||
stopping["flag"] = True
|
|
||||||
kill_children()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, on_signal)
|
|
||||||
signal.signal(signal.SIGINT, on_signal)
|
|
||||||
|
|
||||||
# Poll the children (via subprocess so its bookkeeping stays consistent --
|
|
||||||
# don't os.wait() out from under it). The first unexpected exit means a
|
|
||||||
# screen dropped its mpv: tear the rest down and exit non-zero so systemd
|
|
||||||
# restarts the unit, re-enumerating displays in the process. time.sleep is
|
|
||||||
# interrupted by SIGTERM, so a stop is handled promptly too.
|
|
||||||
while not stopping["flag"]:
|
|
||||||
dead = next((p for p in procs if p.poll() is not None), None)
|
|
||||||
if dead is not None:
|
|
||||||
print(f"start-mpv: an mpv exited unexpectedly (pid {dead.pid}), restarting unit", flush=True)
|
|
||||||
kill_children()
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
# Reap whatever's left.
|
|
||||||
for p in procs:
|
|
||||||
try:
|
|
||||||
p.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
p.kill()
|
|
||||||
|
|
||||||
sys.exit(0 if stopping["flag"] else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,24 +1,28 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=mpv persistent player (DRM/HDMI output, JSON IPC)
|
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
|
# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist before
|
||||||
# before mpv tries to grab the display. Without this, a cold-boot race can
|
# X tries to grab them. Without this a cold-boot race can leave X unable to find
|
||||||
# leave mpv running but never rendering to HDMI (you see the console instead),
|
# a screen. (systemd-udev-settle is deprecated but still the simplest reliable
|
||||||
# even though the exact same command works when run by hand later.
|
# gate here; the session script additionally waits for connectors to appear.)
|
||||||
After=local-fs.target systemd-udev-settle.service
|
After=local-fs.target systemd-udev-settle.service
|
||||||
Wants=systemd-udev-settle.service
|
Wants=systemd-udev-settle.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=${MEDIAPI_USER}
|
User=${MEDIAPI_USER}
|
||||||
SupplementaryGroups=video render
|
# 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
|
||||||
RuntimeDirectory=mediapi
|
RuntimeDirectory=mediapi
|
||||||
RuntimeDirectoryMode=0770
|
RuntimeDirectoryMode=0770
|
||||||
# start-mpv.py enumerates the connected HDMI connectors and launches one mpv
|
# xinit starts the X server on VT 7 and runs the session script as its client.
|
||||||
# per screen: the primary carries audio + the app's IPC socket, and each extra
|
# The session mirrors the HDMI outputs (single DRM master) and execs one mpv.
|
||||||
# screen gets a video-only mirror mpv on its own socket. If any mpv dies the
|
# Non-root X is permitted via /etc/X11/Xwrapper.config (installed by install.sh).
|
||||||
# launcher exits non-zero and systemd restarts us, which re-enumerates the
|
ExecStart=/usr/bin/xinit ${PROJECT_DIR}/scripts/mediapi-session.sh -- :0 vt7 -nolisten tcp -keeptty
|
||||||
# displays. See the script header for the full rationale.
|
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_DIR}/scripts/start-mpv.py
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue