Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d52e46d1e | ||
|
|
799a5d5467 | ||
|
|
5972ac2736 | ||
|
|
b610fc9652 | ||
|
|
f65600cff7 | ||
|
|
1290f784e7 |
17 changed files with 429 additions and 332 deletions
15
.env.example
15
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
60
install.sh
60
install.sh
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
92
mediapi/mpv.py
Normal 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)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
slider.value = data.position || 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;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
el("keep-playing-checkbox").checked = !!data.keep_playing;
|
if (Date.now() > keepSuppressUntil) {
|
||||||
|
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 }),
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,15 @@
|
||||||
<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" 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">⏮</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,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
76
scripts/mediapi-watchdog.sh
Executable 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
39
systemd/mediapi-mpv.service.template
Normal file
39
systemd/mediapi-mpv.service.template
Normal 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
|
||||||
15
systemd/mediapi-watchdog.service.template
Normal file
15
systemd/mediapi-watchdog.service.template
Normal 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
|
||||||
Loading…
Reference in a new issue