Compare commits

..

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

17 changed files with 332 additions and 429 deletions

View file

@ -12,10 +12,17 @@ MEDIAPI_PASSWORD=changeme
MEDIAPI_MEDIA_ROOTS=/localmedia MEDIAPI_MEDIA_ROOTS=/localmedia
MEDIAPI_PORT=8080 MEDIAPI_PORT=8080
# --- mpv (the actual player) --- # --- Kodi (the actual player) ---
# mediapi drives mpv over its JSON IPC Unix socket. install.sh installs mpv and # mediapi drives Kodi over its JSON-RPC HTTP endpoint. install.sh installs Kodi,
# runs it as a service on KMS with --input-ipc-server pointed at this socket. # starts it on KMS, and enables its web server with these credentials/port.
#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock # Keep the port different from MEDIAPI_PORT above.
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 passwords, then re-run." >&2 echo " Edit .env with your real AP/login/Kodi 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-mpv mediapi-app mediapi-watchdog; do for unit in mediapi-kodi 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,7 +140,7 @@ 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 # Kodi (the player) runs as MEDIAPI_USER on GBM/KMS 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 mpv (the actual media player -- mediapi drives it via its IPC socket). # Install Kodi (the actual media player -- mediapi just drives it via JSON-RPC).
if ! command -v mpv >/dev/null 2>&1; then if ! command -v kodi-standalone >/dev/null 2>&1; then
echo "==> Installing mpv ..." echo "==> Installing Kodi ..."
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mpv sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends kodi
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,32 +184,36 @@ deploy_current() {
echo "==> Installing systemd units ..." echo "==> Installing systemd units ..."
install_units install_units
sudo systemctl enable mediapi-mpv mediapi-app mediapi-watchdog >/dev/null 2>&1 || true sudo systemctl enable mediapi-kodi mediapi-app >/dev/null 2>&1 || true
# Migration cleanup: earlier versions ran Kodi as the player. A still-running # Enable Kodi's JSON-RPC web server so mediapi can control it. Kodi rewrites
# Kodi keeps the GPU's DRM master and the tty1 seat, which stops mpv from ever # guisettings.xml on exit, so seed it while Kodi is stopped, then start.
# acquiring the display -- and removing a unit file does NOT stop an already KODI_HOME="$(eval echo "~${MEDIAPI_USER}")/.kodi"
# running process. So explicitly tear any Kodi down before starting mpv. echo "==> Enabling Kodi web server (JSON-RPC) on port ${MEDIAPI_KODI_PORT:-8090} ..."
if [[ -e /etc/systemd/system/mediapi-kodi.service ]] || pgrep -x kodi.bin >/dev/null 2>&1; then sudo systemctl stop mediapi-kodi 2>/dev/null || true
echo "==> Removing leftover Kodi player ..." sudo -u "${MEDIAPI_USER}" mkdir -p "${KODI_HOME}/userdata"
sudo systemctl unmask mediapi-kodi.service 2>/dev/null || true sudo -u "${MEDIAPI_USER}" \
sudo systemctl disable --now mediapi-kodi.service 2>/dev/null || true MEDIAPI_KODI_PORT="${MEDIAPI_KODI_PORT:-8090}" \
sudo rm -f /etc/systemd/system/mediapi-kodi.service MEDIAPI_KODI_USER="${MEDIAPI_KODI_USER:-kodi}" \
sudo pkill -9 -x kodi.bin 2>/dev/null || true MEDIAPI_KODI_PASSWORD="${MEDIAPI_KODI_PASSWORD:-kodi}" \
sudo pkill -9 -f kodi-standalone 2>/dev/null || true python3 "${PROJECT_DIR}/scripts/configure-kodi.py" "${KODI_HOME}/userdata/guisettings.xml"
sudo systemctl daemon-reload
fi
echo "==> Starting services ..." echo "==> Starting services ..."
sudo systemctl restart mediapi-mpv sudo systemctl restart mediapi-kodi
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. # Route Kodi's audio to HDMI. Kodi otherwise defaults to the Pi's analog jack
echo "==> Waiting for mpv IPC socket (${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}) ..." # (bcm2835 Headphones). Done live over JSON-RPC once Kodi's web server is up
# (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 sudo test -S "${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"; then if curl -fsS --max-time 3 "$kodi_url" -H 'content-type: application/json' \
echo " mpv is up." -d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' 2>/dev/null | grep -q pong; then
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
@ -236,7 +240,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-kodi mediapi-app || 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 .mpv import MpvClient from .kodi import KodiClient
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 +19,12 @@ def create_app():
register_auth_gate(app) register_auth_gate(app)
mpv = MpvClient(app.config["MPV_SOCKET"]) kodi = KodiClient(
app.player = PlayerStateManager(mpv, app.config["VIDEO_EXTENSIONS"]) app.config["KODI_URL"],
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,10 +75,14 @@ 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 # Kodi does the playback; we drive it over its JSON-RPC HTTP endpoint.
# as its own systemd service with --input-ipc-server pointed at this path # Kodi's web server must be enabled (install.sh configures this) and should
# (install.sh configures this). # be on a different port than this app. Defaults match install.sh.
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock") KODI_HOST = os.environ.get("MEDIAPI_KODI_HOST", "127.0.0.1")
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 = {

73
mediapi/kodi.py Normal file
View file

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

View file

@ -1,92 +0,0 @@
"""Minimal mpv JSON IPC client over a Unix socket.
mpv does the actual playback (hardware-decoded, straight on KMS/DRM); mediapi is
just the phone-facing UI that drives it. mpv runs as its own systemd service
with `--idle --input-ipc-server=<socket>`, so it holds the playlist and keeps
playing on its own even if this app, the phone, or the WiFi drop out -- we only
send it commands and read its state. Uses only the stdlib so the app keeps no
extra deps.
Protocol: connect to the Unix socket, write one `{"command": [...]}` JSON line,
and read newline-delimited JSON back. mpv also emits async `{"event": ...}`
lines on the same stream; we tag each request with a request_id and skip
anything that isn't the matching reply. A fresh connection per command keeps
this stateless (mpv accepts many concurrent IPC connections), mirroring how the
old Kodi client worked.
"""
import json
import socket
class MpvError(Exception):
"""Base class for all mpv control errors."""
class MpvConnectionError(MpvError):
"""Couldn't reach mpv's IPC socket (not running, wrong path, no perms)."""
class MpvCommandError(MpvError):
"""mpv received the command but returned an error (e.g. bad property)."""
class MpvClient:
def __init__(self, socket_path, timeout=4):
self.socket_path = socket_path
self.timeout = timeout
self._id = 0
def command(self, *args):
"""Send one mpv IPC command and return its `data` field. Raises
MpvConnectionError if mpv is unreachable, MpvCommandError if mpv rejects
the command."""
self._id += 1
req_id = self._id
payload = json.dumps({"command": list(args), "request_id": req_id}) + "\n"
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(self.timeout)
sock.connect(self.socket_path)
sock.sendall(payload.encode("utf-8"))
reply = self._read_reply(sock, req_id)
except (OSError, socket.timeout) as exc:
raise MpvConnectionError(str(exc)) from exc
if reply.get("error") != "success":
raise MpvCommandError(f"{args[0]}: {reply.get('error')}")
return reply.get("data")
def _read_reply(self, sock, req_id):
"""Read newline-delimited JSON from mpv until we see the reply whose
request_id matches ours, skipping interleaved async event lines."""
with sock.makefile("r", encoding="utf-8") as stream:
for line in stream:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as exc:
raise MpvConnectionError(f"bad JSON from mpv: {exc}") from exc
if msg.get("request_id") == req_id and "error" in msg:
return msg
raise MpvConnectionError("mpv closed the connection without a reply")
def get_property(self, name):
"""Return a property's value, or raise MpvCommandError if mpv can't
supply it (e.g. `time-pos` while idle -- 'property unavailable')."""
return self.command("get_property", name)
def try_get(self, name, default=None):
"""Like get_property but returns `default` when the property is simply
unavailable (idle player), so callers don't special-case idle state.
A real connection failure still propagates as MpvConnectionError."""
try:
return self.command("get_property", name)
except MpvCommandError:
return default
def set_property(self, name, value):
return self.command("set_property", name, value)

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,28 +13,19 @@ RECONNECT_INTERVAL = 2.0
class PlayerStateManager: class PlayerStateManager:
"""Controls mpv over its JSON IPC socket and caches its playback state. """Owns control of Kodi. A background thread polls Kodi's JSON-RPC for
playback state and drives "keep playing" auto-advance; Flask request
Playback is driven through mpv's own PLAYLIST: playing a file or folder handlers only ever read the cached snapshot or send a command through this
loads the whole folder into mpv's playlist and jumps to the chosen entry. class. Kodi itself does the decoding/output -- mediapi is just the remote.
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): def __init__(self, kodi_client, video_extensions):
self._mpv = mpv_client self._kodi = kodi_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,
@ -42,9 +33,15 @@ class PlayerStateManager:
"duration": None, "duration": None,
"paused": None, "paused": None,
"volume": None, "volume": None,
"keep_playing": True, "keep_playing": False,
} }
# 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()
@ -52,47 +49,67 @@ 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():
try: try:
self._poll_once() self._poll_once()
except MpvConnectionError as exc: except KodiConnectionError as exc:
log.debug("mpv not reachable: %s", exc) log.debug("kodi not reachable: %s", exc)
self._set_disconnected() self._set_disconnected()
time.sleep(RECONNECT_INTERVAL) time.sleep(RECONNECT_INTERVAL)
continue continue
except MpvError as exc: except KodiError as exc:
log.warning("mpv poll error: %s", exc) log.warning("kodi poll error: %s", exc)
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
def _set_disconnected(self): def _set_disconnected(self):
with self._lock: with self._lock:
self._state["connected"] = False self._state["connected"] = False
def _has_media(self): def _active_player(self):
"""True if mpv currently has a file loaded (not idle). Raises """Return the active player dict ({playerid, type}) or None if idle."""
MpvConnectionError if mpv is unreachable.""" players = self._kodi.call("Player.GetActivePlayers")
return self._mpv.try_get("path") is not None for p in players or []:
if p.get("type") in ("video", "audio"):
return p
return players[0] if players else None
def _poll_once(self): def _poll_once(self):
# `path` is unavailable while mpv sits idle; try_get returns None then. player = self._active_player() # raises KodiConnectionError if down
# 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 path is not None: if player is not None:
filename = os.path.basename(path) or self._mpv.try_get("media-title") pid = player["playerid"]
position = self._mpv.try_get("time-pos") props = self._kodi.call(
duration = self._mpv.try_get("duration") "Player.GetProperties",
paused = bool(self._mpv.try_get("pause", False)) playerid=pid,
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
volume = self._mpv.try_get("volume") item = self._kodi.call(
if volume is not None: "Player.GetItem", playerid=pid, properties=["file"]
volume = int(round(volume)) ) or {}
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({
@ -102,9 +119,29 @@ 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):
@ -113,28 +150,23 @@ class PlayerStateManager:
# -- helpers ----------------------------------------------------------- # -- helpers -----------------------------------------------------------
def _play_playlist(self, files, start_index): def _open(self, path):
"""Load `files` into mpv's playlist (folder order) and start at self._kodi.call("Player.Open", item={"file": path})
start_index. mpv advances through them on its own from here on.""" # An Open means playback is starting, so treat the next idle as a real
# `loadfile ... replace` starts a fresh playlist with the first file; # end-of-file (not the brief gap between stop and start).
# append the rest to rebuild the folder in order, then jump to the self._prev_playing = True
# chosen entry so the playlist matches the folder exactly.
self._mpv.command("loadfile", files[0], "replace")
for f in files[1:]:
self._mpv.command("loadfile", f, "append")
if start_index > 0:
self._mpv.set_property("playlist-pos", start_index)
self._apply_repeat()
def _apply_repeat(self): def _active_player_id(self):
"""Set mpv's playlist loop to match keep-playing.""" player = self._active_player()
self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no") return player["playerid"] if player else None
# -- 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._open(path)
# rest of it, starting at the chosen file. # Build the auto-advance queue from the containing folder, positioned at
# the file just started -- so "keep playing" continues through the rest
# of the folder even when you start from the middle.
folder = os.path.dirname(path) folder = os.path.dirname(path)
files = list_video_files(folder, self.video_extensions) files = list_video_files(folder, self.video_extensions)
try: try:
@ -142,43 +174,36 @@ class PlayerStateManager:
except ValueError: except ValueError:
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._open(files[0])
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(): pid = self._active_player_id()
self._mpv.command("cycle", "pause") if pid is not None:
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):
if self._has_media(): pid = self._active_player_id()
self._mpv.command("seek", int(offset_seconds), "relative") if pid is not None:
# Kodi takes a relative jump as value={"seconds": N}.
def seek_to(self, position_seconds): self._kodi.call("Player.Seek", playerid=pid, value={"seconds": int(offset_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._kodi.call("Application.SetVolume", volume=value)
def set_keep_playing(self, enabled): def set_keep_playing(self, enabled):
with self._lock: with self._lock:
self._keep_playing = bool(enabled) self._state["keep_playing"] = bool(enabled)
self._state["keep_playing"] = self._keep_playing
self._apply_repeat()

View file

@ -1,6 +1,6 @@
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from ..mpv import MpvError from ..kodi import KodiError
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 MpvError as exc: except KodiError 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 KodiError 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 KodiError 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 KodiError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503 return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True}) return jsonify({"ok": True})

View file

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

View file

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

View file

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

58
scripts/configure-kodi.py Normal file
View file

@ -0,0 +1,58 @@
#!/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()

View file

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

View file

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

View file

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

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

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