Compare commits

..

6 commits

Author SHA1 Message Date
Jean-Michel Tremblay
9d52e46d1e Add mediapi-watchdog: reboot the Pi if the mpv player wedges
On 2026-07-09 a kernel keyring-GC oops left the player stuck in
uninterruptible D-state while systemd, SSH, and the Flask app stayed
alive -- so the box sat dead all night instead of recovering. A plain
systemd/hardware watchdog only fires on a TOTAL hang and would not have
caught that partial wedge.

mediapi-watchdog.service (Type=simple, Restart=always, runs as root)
pings mpv over its JSON IPC socket every 30s; after ~3 min of continuous
failure it forces a reboot (systemctl reboot -ff, then SysRq as a
kernel-level fallback that works even when userspace is wedged). A
D-state mpv accepts the socket connect but never replies, so the ping
times out and is caught. install.sh installs + enables it.

Verified: no false positives while mpv is healthy; correctly detects a
stopped mpv and reaches the reboot decision (tested in dry-run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:03:45 -04:00
Jean-Michel Tremblay
799a5d5467 Fix mpv never starting: drop the PAMName=login hang, tear down old Kodi
The mpv service inherited Kodi's VT-grabbing block (PAMName=login +
StandardInput=tty + TTYPath=/dev/tty1). Opening a "login" PAM session on
tty1 hangs in systemd's pre-exec setup, so mpv was never exec'd at all --
the service sat "running" as systemd-executor with an empty (mpv) cmdline,
no IPC socket, no output.

Two fixes:

1. Simplify the unit. mpv doesn't need a login session or the tty: as the
   sole DRM client on the seat it becomes DRM master implicitly on first
   open, so plain video+render+audio group membership is enough. Removing
   the PAM/tty block lets mpv actually start, initialize the vc4 KMS
   display, and play (verified end-to-end: play/status/next through the
   Flask API, position advancing on screen).

2. install.sh now tears down any leftover Kodi before starting mpv. The
   real trigger this time was an in-place migration: replacing the unit
   files doesn't stop an already-running Kodi, which kept DRM master and
   the tty1 seat and blocked mpv from ever getting the display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:04:45 -04:00
Jean-Michel Tremblay
5972ac2736 Replace Kodi with mpv as the player; drive it over its JSON IPC socket
Kodi was overkill for a phone-driven "play this video" box: a full media
center (web server, CEC, library DB) whose surface area is exactly what
wedged the Pi -- a video-decode session left Kodi stuck in an
uninterruptible firmware-mailbox call after a kernel keyring Oops, dead
until a power cycle.

mpv is just a video player: hardware-decoded straight on KMS/DRM, no
media-center baggage. It runs as its own --idle systemd service holding
the playlist, so playback keeps going even if the app/phone/WiFi drop --
the same autonomy Kodi's native playlist gave us. The app talks to it
over its JSON IPC Unix socket.

PlayerStateManager keeps the exact same public API, so the Flask routes,
templates, and UI are unchanged (only the engine underneath swaps out).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:16:20 -04:00
Jean-Michel Tremblay
b610fc9652 Drive playback via Kodi's native playlist so it never depends on the app
Playback stopped advancing whenever the mediapi app disconnected/restarted,
because auto-advance ran in the app's poll loop. Move it into Kodi: playing a
file/folder loads the whole folder into Kodi's video playlist and starts it, so
Kodi advances (and loops, when keep-playing is on) entirely on its own -- it
keeps running even if the app, phone, or WiFi drop.

- play_file/play_folder build the Kodi video playlist (Playlist.Clear/Add +
  Player.Open at the chosen position); remove the in-app queue + poll-advance
- next/previous are now Player.GoTo next/previous (playlist navigation)
- keep-playing maps to Kodi repeat: on=all (loop folder), off=play through once;
  default ON, applied via Player.SetRepeat
- checkbox no longer clobbered by the poll for 1.5s after a manual toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:53:01 -04:00
Jean-Michel Tremblay
f65600cff7 Make the progress bar draggable to seek to a position
The seek slider was display-only; enable it as a scrubber. Adds absolute-seek
in the backend (PlayerStateManager.seek_to via Kodi Player.Seek with an absolute
time) and the /api/control/seekto route. The slider sends the seek on release;
the poll loop stops overwriting the slider while dragging and for a short window
after a seek so it doesn't snap back before Kodi reports the new position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:15:09 -04:00
Jean-Michel Tremblay
1290f784e7 Add Next/Previous clip buttons
Skip forward/back through the current folder queue -- the player already keeps
the folder as an ordered queue with an index, so next/previous just move within
it (no-op at the ends). New PlayerStateManager.next/previous/skip, the
/api/control/next|previous routes, and compact ⏮/⏭ buttons in the transport row.

Lets you skip clips completely without a resume/history feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:53:35 -04:00
17 changed files with 429 additions and 332 deletions

View file

@ -12,17 +12,10 @@ MEDIAPI_PASSWORD=changeme
MEDIAPI_MEDIA_ROOTS=/localmedia MEDIAPI_MEDIA_ROOTS=/localmedia
MEDIAPI_PORT=8080 MEDIAPI_PORT=8080
# --- Kodi (the actual player) --- # --- mpv (the actual player) ---
# mediapi drives Kodi over its JSON-RPC HTTP endpoint. install.sh installs Kodi, # mediapi drives mpv over its JSON IPC Unix socket. install.sh installs mpv and
# starts it on KMS, and enables its web server with these credentials/port. # runs it as a service on KMS with --input-ipc-server pointed at this socket.
# Keep the port different from MEDIAPI_PORT above. #MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock
MEDIAPI_KODI_HOST=127.0.0.1
MEDIAPI_KODI_PORT=8090
MEDIAPI_KODI_USER=kodi
MEDIAPI_KODI_PASSWORD=changeme
# Kodi audio output. Default routes to the Pi's first HDMI port (Kodi otherwise
# picks the analog jack). Override for the 2nd HDMI port (vc4hdmi1) if needed.
#MEDIAPI_KODI_AUDIO_DEVICE=ALSA:hdmi:CARD=vc4hdmi0,DEV=0|vc4-hdmi-0
# --- 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

View file

@ -45,7 +45,7 @@ TARGET_REF="${1:-}"
# script can proceed, but make it loud: the defaults ship an insecure AP. # 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 "WARNING: .env not found -- creating it from .env.example." >&2
echo " Edit .env with your real AP/login/Kodi passwords, then re-run." >&2 echo " Edit .env with your real AP/login passwords, then re-run." >&2
cp .env.example .env cp .env.example .env
fi fi
set -a set -a
@ -116,7 +116,7 @@ bootstrap_system() {
# --- render + install systemd units (used again on rollback) -------- # --- render + install systemd units (used again on rollback) --------
install_units() { install_units() {
for unit in mediapi-kodi mediapi-app; do for unit in mediapi-mpv mediapi-app mediapi-watchdog; 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,7 +140,7 @@ deploy_current() {
chmod 600 instance/secret_key chmod 600 instance/secret_key
fi fi
# Kodi (the player) runs as MEDIAPI_USER on GBM/KMS and needs these groups to # mpv (the player) runs as MEDIAPI_USER on KMS/DRM and needs these groups to
# reach the GPU/DRM, audio, input and console devices. Idempotent; systemd # reach the GPU/DRM, audio, input and console devices. Idempotent; systemd
# picks up the new membership when it (re)starts the service below. # picks up the new membership when it (re)starts the service below.
if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then
@ -148,10 +148,10 @@ deploy_current() {
sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}" sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}"
fi fi
# Install Kodi (the actual media player -- mediapi just drives it via JSON-RPC). # Install mpv (the actual media player -- mediapi drives it via its IPC socket).
if ! command -v kodi-standalone >/dev/null 2>&1; then if ! command -v mpv >/dev/null 2>&1; then
echo "==> Installing Kodi ..." echo "==> Installing mpv ..."
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends kodi sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mpv
fi fi
# Ensure the media browse roots exist so browsing works and there's a place to # Ensure the media browse roots exist so browsing works and there's a place to
@ -184,36 +184,32 @@ deploy_current() {
echo "==> Installing systemd units ..." echo "==> Installing systemd units ..."
install_units install_units
sudo systemctl enable mediapi-kodi mediapi-app >/dev/null 2>&1 || true sudo systemctl enable mediapi-mpv mediapi-app mediapi-watchdog >/dev/null 2>&1 || true
# Enable Kodi's JSON-RPC web server so mediapi can control it. Kodi rewrites # Migration cleanup: earlier versions ran Kodi as the player. A still-running
# guisettings.xml on exit, so seed it while Kodi is stopped, then start. # Kodi keeps the GPU's DRM master and the tty1 seat, which stops mpv from ever
KODI_HOME="$(eval echo "~${MEDIAPI_USER}")/.kodi" # acquiring the display -- and removing a unit file does NOT stop an already
echo "==> Enabling Kodi web server (JSON-RPC) on port ${MEDIAPI_KODI_PORT:-8090} ..." # running process. So explicitly tear any Kodi down before starting mpv.
sudo systemctl stop mediapi-kodi 2>/dev/null || true if [[ -e /etc/systemd/system/mediapi-kodi.service ]] || pgrep -x kodi.bin >/dev/null 2>&1; then
sudo -u "${MEDIAPI_USER}" mkdir -p "${KODI_HOME}/userdata" echo "==> Removing leftover Kodi player ..."
sudo -u "${MEDIAPI_USER}" \ sudo systemctl unmask mediapi-kodi.service 2>/dev/null || true
MEDIAPI_KODI_PORT="${MEDIAPI_KODI_PORT:-8090}" \ sudo systemctl disable --now mediapi-kodi.service 2>/dev/null || true
MEDIAPI_KODI_USER="${MEDIAPI_KODI_USER:-kodi}" \ sudo rm -f /etc/systemd/system/mediapi-kodi.service
MEDIAPI_KODI_PASSWORD="${MEDIAPI_KODI_PASSWORD:-kodi}" \ sudo pkill -9 -x kodi.bin 2>/dev/null || true
python3 "${PROJECT_DIR}/scripts/configure-kodi.py" "${KODI_HOME}/userdata/guisettings.xml" sudo pkill -9 -f kodi-standalone 2>/dev/null || true
sudo systemctl daemon-reload
fi
echo "==> Starting services ..." echo "==> Starting services ..."
sudo systemctl restart mediapi-kodi sudo systemctl restart mediapi-mpv
sudo systemctl restart mediapi-app sudo systemctl restart mediapi-app
sudo systemctl restart mediapi-watchdog
# Route Kodi's audio to HDMI. Kodi otherwise defaults to the Pi's analog jack # Wait for mpv's IPC socket so a first-run deploy leaves a driveable player.
# (bcm2835 Headphones). Done live over JSON-RPC once Kodi's web server is up echo "==> Waiting for mpv IPC socket (${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}) ..."
# (more robust than seeding guisettings, since Kodi validates the device).
local kodi_url="http://${MEDIAPI_KODI_USER:-kodi}:${MEDIAPI_KODI_PASSWORD:-kodi}@127.0.0.1:${MEDIAPI_KODI_PORT:-8090}/jsonrpc"
local audio_dev="${MEDIAPI_KODI_AUDIO_DEVICE:-ALSA:hdmi:CARD=vc4hdmi0,DEV=0|vc4-hdmi-0}"
echo "==> Setting Kodi audio output to HDMI ..."
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl -fsS --max-time 3 "$kodi_url" -H 'content-type: application/json' \ if sudo test -S "${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"; then
-d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' 2>/dev/null | grep -q pong; then echo " mpv is up."
curl -fsS --max-time 5 "$kodi_url" -H 'content-type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"Settings.SetSettingValue\",\"params\":{\"setting\":\"audiooutput.audiodevice\",\"value\":\"${audio_dev}\"}}" >/dev/null \
&& echo " audio -> ${audio_dev}" || echo " WARNING: could not set Kodi audio device"
break break
fi fi
sleep 1 sleep 1
@ -240,7 +236,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-kodi mediapi-app || true systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app mediapi-watchdog || true
exit 0 exit 0
fi fi

View file

@ -2,7 +2,7 @@ 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 .kodi import KodiClient 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,12 +19,8 @@ def create_app():
register_auth_gate(app) register_auth_gate(app)
kodi = KodiClient( mpv = MpvClient(app.config["MPV_SOCKET"])
app.config["KODI_URL"], app.player = PlayerStateManager(mpv, app.config["VIDEO_EXTENSIONS"])
username=app.config["KODI_USER"],
password=app.config["KODI_PASSWORD"],
)
app.player = PlayerStateManager(kodi, app.config["VIDEO_EXTENSIONS"])
app.player.start() app.player.start()
return app return app

View file

@ -75,14 +75,10 @@ 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
] ]
# Kodi does the playback; we drive it over its JSON-RPC HTTP endpoint. # mpv does the playback; we drive it over its JSON IPC Unix socket. mpv runs
# Kodi's web server must be enabled (install.sh configures this) and should # as its own systemd service with --input-ipc-server pointed at this path
# be on a different port than this app. Defaults match install.sh. # (install.sh configures this).
KODI_HOST = os.environ.get("MEDIAPI_KODI_HOST", "127.0.0.1") MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
KODI_PORT = int(os.environ.get("MEDIAPI_KODI_PORT", "8090"))
KODI_USER = os.environ.get("MEDIAPI_KODI_USER", "kodi")
KODI_PASSWORD = os.environ.get("MEDIAPI_KODI_PASSWORD", "kodi")
KODI_URL = f"http://{KODI_HOST}:{KODI_PORT}/jsonrpc"
PORT = int(os.environ.get("MEDIAPI_PORT", "8080")) PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

View file

@ -1,73 +0,0 @@
"""Minimal Kodi JSON-RPC client over HTTP.
Kodi does the actual playback (hardware-decoded, straight on KMS, like
LibreELEC); mediapi is just the phone-facing UI that drives it. We talk to
Kodi's web server JSON-RPC endpoint (Settings > Services > Control > "Allow
remote control via HTTP"). Uses only the stdlib so the app keeps no extra deps.
"""
import base64
import json
import socket
import urllib.error
import urllib.request
class KodiError(Exception):
"""Base class for all Kodi control errors."""
class KodiConnectionError(KodiError):
"""Couldn't reach Kodi's JSON-RPC endpoint (down, wrong port, auth)."""
class KodiCommandError(KodiError):
"""Kodi received the request but returned a JSON-RPC error."""
class KodiClient:
def __init__(self, url, username=None, password=None, timeout=4):
self.url = url
self.timeout = timeout
self._auth = None
if username:
token = base64.b64encode(f"{username}:{password or ''}".encode()).decode()
self._auth = "Basic " + token
self._id = 0
def call(self, method, **params):
"""Invoke a JSON-RPC method and return its "result". Raises
KodiConnectionError if Kodi is unreachable, KodiCommandError if Kodi
rejects the request."""
self._id += 1
body = json.dumps(
{"jsonrpc": "2.0", "id": self._id, "method": method, "params": params}
).encode("utf-8")
req = urllib.request.Request(
self.url, data=body, headers={"Content-Type": "application/json"}
)
if self._auth:
req.add_header("Authorization", self._auth)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
msg = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, socket.timeout) as exc:
raise KodiConnectionError(str(exc)) from exc
except json.JSONDecodeError as exc:
raise KodiConnectionError(f"bad JSON from kodi: {exc}") from exc
if isinstance(msg, dict) and "error" in msg:
raise KodiCommandError(str(msg["error"]))
return msg.get("result") if isinstance(msg, dict) else None
def seconds_from_time(t):
"""Kodi returns times as {hours, minutes, seconds, milliseconds}; flatten
to float seconds. Returns None for a missing/empty value."""
if not isinstance(t, dict):
return None
return (
t.get("hours", 0) * 3600
+ t.get("minutes", 0) * 60
+ t.get("seconds", 0)
+ t.get("milliseconds", 0) / 1000.0
)

92
mediapi/mpv.py Normal file
View file

@ -0,0 +1,92 @@
"""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)

View file

@ -3,8 +3,8 @@ import os
import threading import threading
import time import time
from .kodi import KodiClient, KodiConnectionError, KodiError, seconds_from_time
from .media import list_video_files from .media import list_video_files
from .mpv import MpvConnectionError, MpvError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -13,19 +13,28 @@ RECONNECT_INTERVAL = 2.0
class PlayerStateManager: class PlayerStateManager:
"""Owns control of Kodi. A background thread polls Kodi's JSON-RPC for """Controls mpv over its JSON IPC socket and caches its playback state.
playback state and drives "keep playing" auto-advance; Flask request
handlers only ever read the cached snapshot or send a command through this Playback is driven through mpv's own PLAYLIST: playing a file or folder
class. Kodi itself does the decoding/output -- mediapi is just the remote. loads the whole folder into mpv's playlist and jumps to the chosen entry.
mpv then advances through the folder (and loops it, when "keep playing" is
on) ALL BY ITSELF -- so playback keeps going even if this app, the phone, or
the WiFi disconnect. mediapi only issues commands and polls state; it is
never in the playback loop. Next/Previous are mpv playlist navigation; "keep
playing" is mpv's `loop-playlist`.
""" """
def __init__(self, kodi_client, video_extensions): def __init__(self, mpv_client, video_extensions):
self._kodi = kodi_client self._mpv = mpv_client
self.video_extensions = video_extensions self.video_extensions = video_extensions
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,
@ -33,15 +42,9 @@ class PlayerStateManager:
"duration": None, "duration": None,
"paused": None, "paused": None,
"volume": None, "volume": None,
"keep_playing": False, "keep_playing": True,
} }
# Auto-advance queue (the containing folder), same model as before.
self._queue_folder = None
self._queue_files = []
self._queue_index = -1
self._prev_playing = False
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()
@ -49,67 +52,47 @@ class PlayerStateManager:
def stop(self): def stop(self):
self._stop.set() self._stop.set()
# -- background loop ------------------------------------------------- # -- background loop (status only; mpv owns auto-advance) --------------
def _run(self): def _run(self):
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
self._poll_once() self._poll_once()
except KodiConnectionError as exc: except MpvConnectionError as exc:
log.debug("kodi not reachable: %s", exc) log.debug("mpv not reachable: %s", exc)
self._set_disconnected() self._set_disconnected()
time.sleep(RECONNECT_INTERVAL) time.sleep(RECONNECT_INTERVAL)
continue continue
except KodiError as exc: except MpvError as exc:
log.warning("kodi poll error: %s", exc) log.warning("mpv poll error: %s", exc)
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
def _set_disconnected(self): def _set_disconnected(self):
with self._lock: with self._lock:
self._state["connected"] = False self._state["connected"] = False
def _active_player(self): def _has_media(self):
"""Return the active player dict ({playerid, type}) or None if idle.""" """True if mpv currently has a file loaded (not idle). Raises
players = self._kodi.call("Player.GetActivePlayers") MpvConnectionError if mpv is unreachable."""
for p in players or []: return self._mpv.try_get("path") is not None
if p.get("type") in ("video", "audio"):
return p
return players[0] if players else None
def _poll_once(self): def _poll_once(self):
player = self._active_player() # raises KodiConnectionError if down # `path` is unavailable while mpv sits idle; try_get returns None then.
# A genuine socket failure raises MpvConnectionError and marks us down.
path = self._mpv.try_get("path") # raises MpvConnectionError if down
filename = position = duration = None filename = position = duration = None
paused = None paused = None
playing = player is not None
if player is not None: if path is not None:
pid = player["playerid"] filename = os.path.basename(path) or self._mpv.try_get("media-title")
props = self._kodi.call( position = self._mpv.try_get("time-pos")
"Player.GetProperties", duration = self._mpv.try_get("duration")
playerid=pid, paused = bool(self._mpv.try_get("pause", False))
properties=["time", "totaltime", "speed"],
) or {}
position = seconds_from_time(props.get("time"))
duration = seconds_from_time(props.get("totaltime"))
paused = props.get("speed", 1) == 0
item = self._kodi.call( volume = self._mpv.try_get("volume")
"Player.GetItem", playerid=pid, properties=["file"] if volume is not None:
) or {} volume = int(round(volume))
item = item.get("item", {})
path = item.get("file")
filename = os.path.basename(path) if path else item.get("label")
app = self._kodi.call(
"Application.GetProperties", properties=["volume"]
) or {}
volume = app.get("volume")
# Auto-advance: playback just ended (was playing, now nothing active).
if self._prev_playing and not playing:
self._maybe_advance()
self._prev_playing = playing
with self._lock: with self._lock:
self._state.update({ self._state.update({
@ -119,29 +102,9 @@ 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 Kodi 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._open(next_file)
except KodiError as exc:
log.warning("auto-advance failed: %s", exc)
# -- public read API --------------------------------------------------- # -- public read API ---------------------------------------------------
def get_status(self): def get_status(self):
@ -150,23 +113,28 @@ class PlayerStateManager:
# -- helpers ----------------------------------------------------------- # -- helpers -----------------------------------------------------------
def _open(self, path): def _play_playlist(self, files, start_index):
self._kodi.call("Player.Open", item={"file": path}) """Load `files` into mpv's playlist (folder order) and start at
# An Open means playback is starting, so treat the next idle as a real start_index. mpv advances through them on its own from here on."""
# end-of-file (not the brief gap between stop and start). # `loadfile ... replace` starts a fresh playlist with the first file;
self._prev_playing = True # append the rest to rebuild the folder in order, then jump to the
# chosen entry so the playlist matches the folder exactly.
self._mpv.command("loadfile", files[0], "replace")
for f in files[1:]:
self._mpv.command("loadfile", f, "append")
if start_index > 0:
self._mpv.set_property("playlist-pos", start_index)
self._apply_repeat()
def _active_player_id(self): def _apply_repeat(self):
player = self._active_player() """Set mpv's playlist loop to match keep-playing."""
return player["playerid"] if player else None self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no")
# -- public command API (called from Flask routes) --------------------- # -- public command API (called from Flask routes) ---------------------
def play_file(self, path): def play_file(self, path):
self._open(path) # Queue the whole containing folder so playback continues through the
# Build the auto-advance queue from the containing folder, positioned at # rest of it, starting at the chosen file.
# 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:
@ -174,36 +142,43 @@ class PlayerStateManager:
except ValueError: except ValueError:
files = [path] files = [path]
index = 0 index = 0
with self._lock: self._play_playlist(files, index)
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._open(files[0]) self._play_playlist(files, 0)
with self._lock:
self._queue_folder = folder_path
self._queue_files = files
self._queue_index = 0
def playpause(self): def playpause(self):
pid = self._active_player_id() if self._has_media():
if pid is not None: self._mpv.command("cycle", "pause")
self._kodi.call("Player.PlayPause", playerid=pid)
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):
pid = self._active_player_id() if self._has_media():
if pid is not None: self._mpv.command("seek", int(offset_seconds), "relative")
# Kodi takes a relative jump as value={"seconds": N}.
self._kodi.call("Player.Seek", playerid=pid, value={"seconds": int(offset_seconds)}) 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._kodi.call("Application.SetVolume", volume=value) self._mpv.set_property("volume", value)
def set_keep_playing(self, enabled): def set_keep_playing(self, enabled):
with self._lock: with self._lock:
self._state["keep_playing"] = bool(enabled) self._keep_playing = bool(enabled)
self._state["keep_playing"] = self._keep_playing
self._apply_repeat()

View file

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

View file

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

View file

@ -3,6 +3,9 @@
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);
@ -102,17 +105,23 @@
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;
}
}); });
} }
@ -120,6 +129,14 @@
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 }) });
}); });
@ -128,6 +145,20 @@
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;
@ -137,6 +168,7 @@
}); });
el("keep-playing-checkbox").addEventListener("change", (e) => { el("keep-playing-checkbox").addEventListener("change", (e) => {
keepSuppressUntil = Date.now() + 1500;
api("/api/control/keep-playing", { api("/api/control/keep-playing", {
method: "POST", method: "POST",
body: JSON.stringify({ enabled: e.target.checked }), body: JSON.stringify({ enabled: e.target.checked }),

View file

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

View file

@ -1,58 +0,0 @@
#!/usr/bin/env python3
"""Enable Kodi's JSON-RPC web server by seeding its guisettings.xml.
mediapi controls Kodi over HTTP JSON-RPC, which requires "Allow remote control
via HTTP" -- a setting normally toggled in Kodi's GUI. This seeds it headlessly.
IMPORTANT: Kodi rewrites guisettings.xml when it exits, so this must run while
Kodi is STOPPED (install.sh handles the stop/seed/start ordering). It merges
into any existing file, so re-running is safe.
Reads the desired values from the environment (with sensible defaults matching
.env.example):
MEDIAPI_KODI_PORT, MEDIAPI_KODI_USER, MEDIAPI_KODI_PASSWORD
Usage: configure-kodi.py [path/to/guisettings.xml]
(default: ~/.kodi/userdata/guisettings.xml)
"""
import os
import sys
import xml.etree.ElementTree as ET
DEFAULT_PATH = os.path.expanduser("~/.kodi/userdata/guisettings.xml")
WANTED = {
"services.webserver": os.environ.get("MEDIAPI_KODI_WEBSERVER", "true"),
"services.webserverport": os.environ.get("MEDIAPI_KODI_PORT", "8090"),
"services.webserverusername": os.environ.get("MEDIAPI_KODI_USER", "kodi"),
"services.webserverpassword": os.environ.get("MEDIAPI_KODI_PASSWORD", "kodi"),
"services.webserverauthentication": "true",
"services.webserverssl": "false",
}
def main():
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH
os.makedirs(os.path.dirname(path), exist_ok=True)
if os.path.exists(path):
tree = ET.parse(path)
root = tree.getroot()
else:
root = ET.Element("settings", {"version": "2"})
tree = ET.ElementTree(root)
existing = {el.get("id"): el for el in root.findall("setting")}
for setting_id, value in WANTED.items():
el = existing.get(setting_id)
if el is None:
el = ET.SubElement(root, "setting", {"id": setting_id})
el.text = value
tree.write(path, encoding="utf-8", xml_declaration=True)
print(f"configure-kodi: web server enabled on port {WANTED['services.webserverport']} ({path})")
if __name__ == "__main__":
main()

76
scripts/mediapi-watchdog.sh Executable file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# mediapi-watchdog -- reboot the Pi if the mpv player wedges.
#
# Guards against the failure seen 2026-07-09: a kernel oops left the player
# stuck in uninterruptible (D) state while the rest of the box -- systemd, SSH,
# the Flask app -- stayed alive and responsive. A plain systemd/hardware
# watchdog only fires on a TOTAL system hang, so it would NOT have caught that
# partial wedge. Instead we ping mpv over its JSON IPC socket; if mpv stays
# unresponsive for ~3 minutes we force a reboot, turning a dead-all-night wedge
# into a ~30s auto-recovery.
#
# Runs as root (needs to force a reboot). Installed + enabled by install.sh.
set -u
SOCK="${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"
INTERVAL="${MEDIAPI_WATCHDOG_INTERVAL:-30}" # seconds between checks
FAILS_TO_REBOOT="${MEDIAPI_WATCHDOG_FAILS:-6}" # consecutive fails -> reboot (~3 min)
PING_TIMEOUT="${MEDIAPI_WATCHDOG_PING_TIMEOUT:-6}" # per-check hard timeout
DRYRUN="${MEDIAPI_WATCHDOG_DRYRUN:-}" # non-empty: log instead of rebooting
# Return 0 iff mpv replies to a JSON IPC command within PING_TIMEOUT. A player
# wedged in D-state accepts the socket connect but never replies, so the recv
# blocks and `timeout` trips it -- exactly the case we want to catch.
ping_mpv() {
timeout "$PING_TIMEOUT" python3 - "$SOCK" <<'PY'
import socket, json, sys
try:
s = socket.socket(socket.AF_UNIX); s.settimeout(4); s.connect(sys.argv[1])
s.sendall(b'{"command":["get_property","mpv-version"],"request_id":1}\n')
buf = b""
while b"\n" not in buf:
chunk = s.recv(4096)
if not chunk:
sys.exit(1)
buf += chunk
for line in buf.decode(errors="replace").splitlines():
m = json.loads(line)
if m.get("request_id") == 1:
sys.exit(0 if m.get("error") == "success" else 1)
sys.exit(1)
except Exception:
sys.exit(1)
PY
}
do_reboot() {
if [ -n "$DRYRUN" ]; then
echo "mediapi-watchdog: DRYRUN -- would reboot now" >&2
return
fi
# Best-effort clean reboot first; fall back to SysRq, which reboots at the
# kernel level even when userspace is wedged in D-state (the case we guard).
sync &
systemctl reboot -ff &
sleep 12
echo 1 > /proc/sys/kernel/sysrq 2>/dev/null || true
echo b > /proc/sysrq-trigger 2>/dev/null || true
}
echo "mediapi-watchdog: watching $SOCK (reboot after ${FAILS_TO_REBOOT}x${INTERVAL}s unresponsive)" >&2
fails=0
while true; do
if ping_mpv; then
fails=0
else
fails=$((fails + 1))
echo "mediapi-watchdog: mpv ping FAILED ($fails/$FAILS_TO_REBOOT)" >&2
if [ "$fails" -ge "$FAILS_TO_REBOOT" ]; then
echo "mediapi-watchdog: mpv unresponsive ~$((INTERVAL * FAILS_TO_REBOOT))s -- rebooting" >&2
do_reboot
fails=0
fi
fi
sleep "$INTERVAL"
done

View file

@ -1,7 +1,7 @@
[Unit] [Unit]
Description=MediaPi Flask app Description=MediaPi Flask app
After=network-online.target mediapi-kodi.service After=network-online.target mediapi-mpv.service
Wants=network-online.target mediapi-kodi.service Wants=network-online.target mediapi-mpv.service
[Service] [Service]
Type=simple Type=simple

View file

@ -1,25 +0,0 @@
[Unit]
Description=Kodi media center (standalone, GBM/KMS -- the actual player)
# Needs the DRM/HDMI + sound devices present; takes over the console VT.
After=local-fs.target systemd-udev-settle.service sound.target
Wants=systemd-udev-settle.service
Conflicts=getty@tty1.service
[Service]
User=${MEDIAPI_USER}
# Kodi on GBM needs: video+render (GPU/DRM), input (remotes/keyboard), audio
# (HDMI/ALSA), tty (own the VT). PAMName=login gives it a real session so it
# can take the seat's devices.
SupplementaryGroups=video render input audio tty dialout
PAMName=login
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
StandardError=journal
# kodi-standalone launches Kodi and relaunches it if it exits from the UI.
ExecStart=/usr/bin/kodi-standalone
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,39 @@
[Unit]
Description=mpv video player (KMS/DRM -- the actual player)
# Needs the DRM/HDMI + sound devices present.
After=local-fs.target systemd-udev-settle.service sound.target
Wants=systemd-udev-settle.service
# Keep a login getty off the primary display so nothing else claims tty1.
Conflicts=getty@tty1.service
[Service]
User=${MEDIAPI_USER}
# mpv on KMS/DRM needs: video+render (GPU/DRM), input (remotes/keyboard), audio
# (HDMI/ALSA). It becomes DRM master simply as the first/only DRM client on the
# seat -- no PAM login session and no elevated capabilities required, provided
# nothing else is already holding the GPU. (An older Kodi-based deploy DID hold
# it; install.sh tears any leftover Kodi down before starting this.)
SupplementaryGroups=video render input audio
# Create /run/mediapi (owned by this user) for the IPC socket the app connects to.
RuntimeDirectory=mediapi
StandardInput=null
StandardOutput=journal
StandardError=journal
# --idle keeps mpv running with no file loaded (holds the playlist between clips
# and after the last one), so the service stays up and the app can always reach
# the socket. Playback renders straight on KMS.
ExecStart=/usr/bin/mpv \
--idle=yes \
--input-ipc-server=/run/mediapi/mpv.sock \
--no-terminal \
--no-input-default-bindings \
--fullscreen \
--force-window=yes \
--keep-open=no \
--vo=gpu --gpu-context=drm --hwdec=auto \
--ao=alsa --audio-device=alsa/hdmi:CARD=vc4hdmi0,DEV=0
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,15 @@
[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