Compare commits
No commits in common. "master" and "0.0.1" have entirely different histories.
16 changed files with 475 additions and 514 deletions
13
.env.example
13
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
78
README.md
78
README.md
|
|
@ -110,45 +110,45 @@ 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
|
||||||
LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is
|
directly (DRM/KMS, no desktop needed); the phone browser only shows
|
||||||
just the phone-facing **remote**: it browses the media folders and drives Kodi
|
metadata/controls, never the video image itself.
|
||||||
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.** A single mpv on DRM can only drive one connector, and
|
||||||
Kodi's player state (position/duration/pause/volume) and drives "keep playing"
|
the Pi's `vc4-kms` driver can't clone two HDMI outputs onto one framebuffer —
|
||||||
auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and
|
so mirroring is done by running *one mpv per connected screen*.
|
||||||
enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see
|
`scripts/start-mpv.py` (launched by the `mediapi-mpv` unit) enumerates the
|
||||||
`scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env`
|
connected HDMI connectors and starts a **primary** mpv (audio + the app's IPC
|
||||||
(`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`.
|
socket, `mpv.sock`) plus a **mirror** mpv per extra screen (video-only, on
|
||||||
|
`mpv-mirror-<connector>.sock`). The app plays/pauses/seeks the primary and
|
||||||
> **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software
|
echoes those to the mirrors best-effort, re-syncing on every video. One screen
|
||||||
> (DRM master is exclusive per card; the X-mirror path can't keep up). Drive
|
→ same as before; a dead mirror just leaves that screen dark, never the audio
|
||||||
> both car screens from one HDMI port through an external powered HDMI splitter.
|
screen. Because each screen runs its own mpv, the same file decodes once per
|
||||||
|
screen (`--hwdec=auto-safe` falls back to software if the HW decoder is busy).
|
||||||
|
|
||||||
### First-time install (on the Pi)
|
### First-time install (on the Pi)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# system deps: mpv for HDMI/DRM playback
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y mpv
|
||||||
|
|
||||||
# install uv (Python package/venv manager) if not already present
|
# install uv (Python package/venv manager) if not already present
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
|
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
|
||||||
|
|
||||||
# create your .env (see Configuration above)
|
# create your .env (see Configuration above)
|
||||||
cp .env.example .env && $EDITOR .env
|
cp .env.example .env && $EDITOR .env
|
||||||
|
|
||||||
# deploy the currently-checked-out ref: 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 +192,20 @@ cd ~/mediapi && ./install.sh v1.2.0
|
||||||
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
|
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes / things to double check on the actual hardware:
|
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 picks up whatever HDMI connectors read `connected` under
|
||||||
screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's
|
`/sys/class/drm/card*-HDMI-*/status` at service start — so plug in both
|
||||||
own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the
|
screens **before** `mediapi-mpv` starts (or `sudo systemctl restart
|
||||||
`video render input audio tty` groups (install.sh adds them).
|
mediapi-mpv` after). Check what it launched with
|
||||||
* If the web API is unreachable (`Connection refused` on
|
`sudo journalctl -u mediapi-mpv | grep start-mpv` and confirm both sockets:
|
||||||
`:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites
|
`ls -l /run/mediapi/mpv*.sock`. `mpv --drm-connector=help` (with a display
|
||||||
`guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the
|
attached) lists the connector names if the sysfs guess is ever wrong.
|
||||||
setting via `scripts/configure-kodi.py`, and restarts), or toggle
|
* If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA
|
||||||
Settings → Services → Control → *Allow remote control via HTTP* in the Kodi
|
device name (usually `vc4-hdmi`) and add e.g.
|
||||||
GUI once. Verify with `JSONRPC.Ping` (see install snippet above).
|
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
|
||||||
* Kodi handles HDMI audio itself (Settings → System → Audio). If there's no
|
run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
|
||||||
sound, set the audio output device to the HDMI sink there.
|
|
||||||
|
|
||||||
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`
|
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`
|
||||||
|
|
|
||||||
92
install.sh
92
install.sh
|
|
@ -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,30 +116,14 @@ 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. Idempotent; systemd picks up the
|
||||||
# picks up the new membership when it (re)starts the service below.
|
# 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 groups ..."
|
||||||
sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}"
|
sudo usermod -aG video,render "${MEDIAPI_USER}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install mpv (the actual media player -- mediapi drives it via its IPC socket).
|
|
||||||
if ! command -v mpv >/dev/null 2>&1; then
|
|
||||||
echo "==> Installing mpv ..."
|
|
||||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mpv
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure the media browse roots exist so browsing works and there's a place to
|
|
||||||
# copy content into. (mkdir -p is a no-op if it's already a mount point.)
|
|
||||||
IFS=':' read -ra _media_roots <<< "${MEDIAPI_MEDIA_ROOTS:-/localmedia}"
|
|
||||||
for r in "${_media_roots[@]}"; do
|
|
||||||
[[ -n "$r" && ! -d "$r" ]] || continue
|
|
||||||
echo "==> Creating media root $r ..."
|
|
||||||
sudo mkdir -p "$r"
|
|
||||||
sudo chown "${MEDIAPI_USER}:${MEDIAPI_USER}" "$r"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "==> Applying WiFi country + AP config ..."
|
echo "==> Applying WiFi country + AP config ..."
|
||||||
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
|
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
|
||||||
if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then
|
if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then
|
||||||
|
|
@ -182,38 +142,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 +161,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 +168,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,11 @@ 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"],
|
||||||
|
mirror_glob=app.config.get("MPV_MIRROR_GLOB"),
|
||||||
|
)
|
||||||
app.player.start()
|
app.player.start()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,11 @@ class Config:
|
||||||
MEDIA_ROOTS = [
|
MEDIA_ROOTS = [
|
||||||
p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p
|
p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p
|
||||||
]
|
]
|
||||||
# mpv does the playback; we drive it over its JSON IPC Unix socket. mpv runs
|
|
||||||
# as its own systemd service with --input-ipc-server pointed at this path
|
|
||||||
# (install.sh configures this).
|
|
||||||
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
|
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
|
||||||
|
# Extra mpv instances (one per additional HDMI screen) expose sockets named
|
||||||
|
# mpv-mirror-<connector>.sock alongside the primary socket. The player
|
||||||
|
# discovers them by glob and echoes playback commands to them best-effort.
|
||||||
|
MPV_MIRROR_GLOB = os.path.join(os.path.dirname(MPV_SOCKET), "mpv-mirror-*.sock")
|
||||||
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
|
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
|
||||||
|
|
||||||
VIDEO_EXTENSIONS = {
|
VIDEO_EXTENSIONS = {
|
||||||
|
|
|
||||||
|
|
@ -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
85
mediapi/mpv_ipc.py
Normal 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)
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .media import list_video_files
|
from .media import list_video_files
|
||||||
from .mpv import MpvConnectionError, MpvError
|
from .mpv_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -13,28 +14,26 @@ 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, mirror_glob=None):
|
||||||
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
|
||||||
|
# Sockets of any per-screen "mirror" mpv instances (see start-mpv.py).
|
||||||
|
# We only ever push playback commands to these best-effort; the primary
|
||||||
|
# socket above is the sole source of state and the one that carries
|
||||||
|
# audio, so a missing/broken mirror just means one screen is dark.
|
||||||
|
self._mirror_glob = mirror_glob
|
||||||
|
self._mirror_clients = {}
|
||||||
|
self._mirror_lock = threading.Lock()
|
||||||
|
|
||||||
|
self._client = MpvIPCClient(socket_path)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
|
|
||||||
# Desired repeat state. Default ON: a lean-back player (kids' videos in a
|
|
||||||
# car) should keep running through/looping the folder, not stop.
|
|
||||||
self._keep_playing = True
|
|
||||||
|
|
||||||
self._state = {
|
self._state = {
|
||||||
"connected": False,
|
"connected": False,
|
||||||
"filename": None,
|
"filename": None,
|
||||||
|
|
@ -42,9 +41,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 +56,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:
|
try:
|
||||||
self._poll_once()
|
self._client.connect()
|
||||||
except MpvConnectionError as exc:
|
log.info("connected to mpv socket at %s", self.socket_path)
|
||||||
log.debug("mpv not reachable: %s", exc)
|
except OSError:
|
||||||
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)
|
try:
|
||||||
|
self._poll_once()
|
||||||
|
except MpvConnectionError as exc:
|
||||||
|
log.warning("lost connection to mpv: %s", exc)
|
||||||
|
self._set_disconnected()
|
||||||
|
time.sleep(RECONNECT_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
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 +115,108 @@ class PlayerStateManager:
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
"paused": paused,
|
"paused": paused,
|
||||||
"volume": volume,
|
"volume": volume,
|
||||||
"keep_playing": self._keep_playing,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _maybe_advance(self):
|
||||||
|
"""Called from the poll loop when mpv just went idle. If keep-playing
|
||||||
|
is on and there's a next file in the current folder queue, load it."""
|
||||||
|
with self._lock:
|
||||||
|
keep_playing = self._state["keep_playing"]
|
||||||
|
has_next = (
|
||||||
|
self._queue_files
|
||||||
|
and 0 <= self._queue_index + 1 < len(self._queue_files)
|
||||||
|
)
|
||||||
|
if keep_playing and has_next:
|
||||||
|
self._queue_index += 1
|
||||||
|
next_file = self._queue_files[self._queue_index]
|
||||||
|
else:
|
||||||
|
next_file = None
|
||||||
|
|
||||||
|
if next_file:
|
||||||
|
try:
|
||||||
|
self._client.command("loadfile", next_file, "replace")
|
||||||
|
except MpvIPCError as exc:
|
||||||
|
log.warning("auto-advance loadfile failed: %s", exc)
|
||||||
|
self._broadcast("loadfile", next_file, "replace")
|
||||||
|
|
||||||
# -- public read API ---------------------------------------------------
|
# -- public read API ---------------------------------------------------
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._state)
|
state = dict(self._state)
|
||||||
|
state["keep_playing"] = self._state["keep_playing"]
|
||||||
|
return state
|
||||||
|
|
||||||
# -- helpers -----------------------------------------------------------
|
# -- mirror screens (best-effort) --------------------------------------
|
||||||
|
|
||||||
def _play_playlist(self, files, start_index):
|
def _broadcast(self, *command_args):
|
||||||
"""Load `files` into mpv's playlist (folder order) and start at
|
"""Echo a playback command to every mirror mpv, best-effort. Never
|
||||||
start_index. mpv advances through them on its own from here on."""
|
raises: a mirror that's absent, mid-restart, or wedged must not affect
|
||||||
# `loadfile ... replace` starts a fresh playlist with the first file;
|
the primary screen or the HTTP response. Mirrors are re-synced on every
|
||||||
# append the rest to rebuild the folder in order, then jump to the
|
loadfile, so a command missed here self-heals at the next video."""
|
||||||
# chosen entry so the playlist matches the folder exactly.
|
if not self._mirror_glob:
|
||||||
self._mpv.command("loadfile", files[0], "replace")
|
return
|
||||||
for f in files[1:]:
|
with self._mirror_lock:
|
||||||
self._mpv.command("loadfile", f, "append")
|
paths = set(glob.glob(self._mirror_glob))
|
||||||
if start_index > 0:
|
# Drop clients whose socket disappeared (display unplugged).
|
||||||
self._mpv.set_property("playlist-pos", start_index)
|
for gone in set(self._mirror_clients) - paths:
|
||||||
self._apply_repeat()
|
self._mirror_clients.pop(gone).close()
|
||||||
|
for path in paths:
|
||||||
def _apply_repeat(self):
|
client = self._mirror_clients.get(path)
|
||||||
"""Set mpv's playlist loop to match keep-playing."""
|
if client is None:
|
||||||
self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no")
|
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):
|
||||||
# Queue the whole containing folder so playback continues through the
|
self._client.command("loadfile", path, "replace")
|
||||||
# rest of it, starting at the chosen file.
|
self._broadcast("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)
|
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")
|
||||||
|
self._broadcast("loadfile", files[0], "replace")
|
||||||
|
with self._lock:
|
||||||
|
self._queue_folder = folder_path
|
||||||
|
self._queue_files = files
|
||||||
|
self._queue_index = 0
|
||||||
|
|
||||||
def playpause(self):
|
def playpause(self):
|
||||||
if self._has_media():
|
self._client.command("cycle", "pause")
|
||||||
self._mpv.command("cycle", "pause")
|
self._broadcast("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")
|
self._broadcast("seek", 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()
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
// (the player takes a beat to report the new position).
|
|
||||||
if (!seeking && Date.now() > seekSuppressUntil) {
|
|
||||||
slider.value = data.position || 0;
|
slider.value = data.position || 0;
|
||||||
el("time-pos").textContent = formatTime(data.position);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.activeElement !== el("volume-slider")) {
|
if (document.activeElement !== el("volume-slider")) {
|
||||||
el("volume-slider").value = data.volume || 0;
|
el("volume-slider").value = data.volume || 0;
|
||||||
}
|
}
|
||||||
if (Date.now() > keepSuppressUntil) {
|
|
||||||
el("keep-playing-checkbox").checked = !!data.keep_playing;
|
el("keep-playing-checkbox").checked = !!data.keep_playing;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,13 @@
|
||||||
<p id="now-playing" class="filename">—</p>
|
<p id="now-playing" class="filename">—</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">⏮</button>
|
|
||||||
<button id="btn-back30">« 30</button>
|
<button id="btn-back30">« 30</button>
|
||||||
<button id="btn-playpause">Play/Pause</button>
|
<button id="btn-playpause">Play/Pause</button>
|
||||||
<button id="btn-fwd30">30 »</button>
|
<button id="btn-fwd30">30 »</button>
|
||||||
<button id="btn-next" title="Next clip">⏭</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="volume-row">
|
<div class="volume-row">
|
||||||
<span>Vol</span>
|
<span>Vol</span>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
171
scripts/start-mpv.py
Executable file
171
scripts/start-mpv.py
Executable file
|
|
@ -0,0 +1,171 @@
|
||||||
|
#!/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,39 +1,26 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=mpv video player (KMS/DRM -- the actual player)
|
Description=mpv persistent player (DRM/HDMI output, JSON IPC)
|
||||||
# Needs the DRM/HDMI + sound devices present.
|
# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist
|
||||||
After=local-fs.target systemd-udev-settle.service sound.target
|
# before mpv tries to grab the display. Without this, a cold-boot race can
|
||||||
|
# leave mpv running but never rendering to HDMI (you see the console instead),
|
||||||
|
# even though the exact same command works when run by hand later.
|
||||||
|
After=local-fs.target systemd-udev-settle.service
|
||||||
Wants=systemd-udev-settle.service
|
Wants=systemd-udev-settle.service
|
||||||
# Keep a login getty off the primary display so nothing else claims tty1.
|
|
||||||
Conflicts=getty@tty1.service
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
Type=simple
|
||||||
User=${MEDIAPI_USER}
|
User=${MEDIAPI_USER}
|
||||||
# mpv on KMS/DRM needs: video+render (GPU/DRM), input (remotes/keyboard), audio
|
SupplementaryGroups=video render
|
||||||
# (HDMI/ALSA). It becomes DRM master simply as the first/only DRM client on the
|
|
||||||
# seat -- no PAM login session and no elevated capabilities required, provided
|
|
||||||
# nothing else is already holding the GPU. (An older Kodi-based deploy DID hold
|
|
||||||
# it; install.sh tears any leftover Kodi down before starting this.)
|
|
||||||
SupplementaryGroups=video render input audio
|
|
||||||
# Create /run/mediapi (owned by this user) for the IPC socket the app connects to.
|
|
||||||
RuntimeDirectory=mediapi
|
RuntimeDirectory=mediapi
|
||||||
StandardInput=null
|
RuntimeDirectoryMode=0770
|
||||||
StandardOutput=journal
|
# start-mpv.py enumerates the connected HDMI connectors and launches one mpv
|
||||||
StandardError=journal
|
# per screen: the primary carries audio + the app's IPC socket, and each extra
|
||||||
# --idle keeps mpv running with no file loaded (holds the playlist between clips
|
# screen gets a video-only mirror mpv on its own socket. If any mpv dies the
|
||||||
# and after the last one), so the service stays up and the app can always reach
|
# launcher exits non-zero and systemd restarts us, which re-enumerates the
|
||||||
# the socket. Playback renders straight on KMS.
|
# displays. See the script header for the full rationale.
|
||||||
ExecStart=/usr/bin/mpv \
|
ExecStart=/usr/bin/python3 ${PROJECT_DIR}/scripts/start-mpv.py
|
||||||
--idle=yes \
|
|
||||||
--input-ipc-server=/run/mediapi/mpv.sock \
|
|
||||||
--no-terminal \
|
|
||||||
--no-input-default-bindings \
|
|
||||||
--fullscreen \
|
|
||||||
--force-window=yes \
|
|
||||||
--keep-open=no \
|
|
||||||
--vo=gpu --gpu-context=drm --hwdec=auto \
|
|
||||||
--ao=alsa --audio-device=alsa/hdmi:CARD=vc4hdmi0,DEV=0
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=2
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue