Replace Kodi with mpv as the player; drive it over its JSON IPC socket
Kodi was overkill for a phone-driven "play this video" box: a full media center (web server, CEC, library DB) whose surface area is exactly what wedged the Pi -- a video-decode session left Kodi stuck in an uninterruptible firmware-mailbox call after a kernel keyring Oops, dead until a power cycle. mpv is just a video player: hardware-decoded straight on KMS/DRM, no media-center baggage. It runs as its own --idle systemd service holding the playlist, so playback keeps going even if the app/phone/WiFi drop -- the same autonomy Kodi's native playlist gave us. The app talks to it over its JSON IPC Unix socket. PlayerStateManager keeps the exact same public API, so the Flask routes, templates, and UI are unchanged (only the engine underneath swaps out). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b610fc9652
commit
5972ac2736
13 changed files with 220 additions and 322 deletions
15
.env.example
15
.env.example
|
|
@ -12,17 +12,10 @@ MEDIAPI_PASSWORD=changeme
|
|||
MEDIAPI_MEDIA_ROOTS=/localmedia
|
||||
MEDIAPI_PORT=8080
|
||||
|
||||
# --- Kodi (the actual player) ---
|
||||
# mediapi drives Kodi over its JSON-RPC HTTP endpoint. install.sh installs Kodi,
|
||||
# starts it on KMS, and enables its web server with these credentials/port.
|
||||
# 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
|
||||
# --- mpv (the actual player) ---
|
||||
# mediapi drives mpv over its JSON IPC Unix socket. install.sh installs mpv and
|
||||
# runs it as a service on KMS with --input-ipc-server pointed at this socket.
|
||||
#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock
|
||||
|
||||
# --- System: the Linux user the systemd services run as ---
|
||||
# Auto-detected from whoever runs install.sh (id -un) when left unset, so you
|
||||
|
|
|
|||
47
install.sh
47
install.sh
|
|
@ -45,7 +45,7 @@ TARGET_REF="${1:-}"
|
|||
# script can proceed, but make it loud: the defaults ship an insecure AP.
|
||||
if [[ ! -f .env ]]; then
|
||||
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
|
||||
fi
|
||||
set -a
|
||||
|
|
@ -116,7 +116,7 @@ bootstrap_system() {
|
|||
|
||||
# --- render + install systemd units (used again on rollback) --------
|
||||
install_units() {
|
||||
for unit in mediapi-kodi mediapi-app; do
|
||||
for unit in mediapi-mpv mediapi-app; do
|
||||
sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \
|
||||
-e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \
|
||||
-e "s|\${UV}|${UV}|g" \
|
||||
|
|
@ -140,7 +140,7 @@ deploy_current() {
|
|||
chmod 600 instance/secret_key
|
||||
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
|
||||
# picks up the new membership when it (re)starts the service below.
|
||||
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}"
|
||||
fi
|
||||
|
||||
# Install Kodi (the actual media player -- mediapi just drives it via JSON-RPC).
|
||||
if ! command -v kodi-standalone >/dev/null 2>&1; then
|
||||
echo "==> Installing Kodi ..."
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends kodi
|
||||
# Install mpv (the actual media player -- mediapi drives it via its IPC socket).
|
||||
if ! command -v mpv >/dev/null 2>&1; then
|
||||
echo "==> Installing mpv ..."
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mpv
|
||||
fi
|
||||
|
||||
# Ensure the media browse roots exist so browsing works and there's a place to
|
||||
|
|
@ -184,36 +184,17 @@ deploy_current() {
|
|||
|
||||
echo "==> Installing systemd units ..."
|
||||
install_units
|
||||
sudo systemctl enable mediapi-kodi mediapi-app >/dev/null 2>&1 || true
|
||||
|
||||
# Enable Kodi's JSON-RPC web server so mediapi can control it. Kodi rewrites
|
||||
# guisettings.xml on exit, so seed it while Kodi is stopped, then start.
|
||||
KODI_HOME="$(eval echo "~${MEDIAPI_USER}")/.kodi"
|
||||
echo "==> Enabling Kodi web server (JSON-RPC) on port ${MEDIAPI_KODI_PORT:-8090} ..."
|
||||
sudo systemctl stop mediapi-kodi 2>/dev/null || true
|
||||
sudo -u "${MEDIAPI_USER}" mkdir -p "${KODI_HOME}/userdata"
|
||||
sudo -u "${MEDIAPI_USER}" \
|
||||
MEDIAPI_KODI_PORT="${MEDIAPI_KODI_PORT:-8090}" \
|
||||
MEDIAPI_KODI_USER="${MEDIAPI_KODI_USER:-kodi}" \
|
||||
MEDIAPI_KODI_PASSWORD="${MEDIAPI_KODI_PASSWORD:-kodi}" \
|
||||
python3 "${PROJECT_DIR}/scripts/configure-kodi.py" "${KODI_HOME}/userdata/guisettings.xml"
|
||||
sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true
|
||||
|
||||
echo "==> Starting services ..."
|
||||
sudo systemctl restart mediapi-kodi
|
||||
sudo systemctl restart mediapi-mpv
|
||||
sudo systemctl restart mediapi-app
|
||||
|
||||
# Route Kodi's audio to HDMI. Kodi otherwise defaults to the Pi's analog jack
|
||||
# (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 ..."
|
||||
# Wait for mpv's IPC socket so a first-run deploy leaves a driveable player.
|
||||
echo "==> Waiting for mpv IPC socket (${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}) ..."
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS --max-time 3 "$kodi_url" -H 'content-type: application/json' \
|
||||
-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"
|
||||
if sudo test -S "${MEDIAPI_MPV_SOCKET:-/run/mediapi/mpv.sock}"; then
|
||||
echo " mpv is up."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
|
|
@ -240,7 +221,7 @@ echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..."
|
|||
if app_healthy; then
|
||||
echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE"
|
||||
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 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from flask import Flask
|
|||
|
||||
from .auth import register_auth_gate
|
||||
from .config import Config
|
||||
from .kodi import KodiClient
|
||||
from .mpv import MpvClient
|
||||
from .player import PlayerStateManager
|
||||
from .routes.api_routes import bp as api_bp
|
||||
from .routes.auth_routes import bp as auth_bp
|
||||
|
|
@ -19,12 +19,8 @@ def create_app():
|
|||
|
||||
register_auth_gate(app)
|
||||
|
||||
kodi = KodiClient(
|
||||
app.config["KODI_URL"],
|
||||
username=app.config["KODI_USER"],
|
||||
password=app.config["KODI_PASSWORD"],
|
||||
)
|
||||
app.player = PlayerStateManager(kodi, app.config["VIDEO_EXTENSIONS"])
|
||||
mpv = MpvClient(app.config["MPV_SOCKET"])
|
||||
app.player = PlayerStateManager(mpv, app.config["VIDEO_EXTENSIONS"])
|
||||
app.player.start()
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -75,14 +75,10 @@ class Config:
|
|||
MEDIA_ROOTS = [
|
||||
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.
|
||||
# Kodi's web server must be enabled (install.sh configures this) and should
|
||||
# be on a different port than this app. Defaults match install.sh.
|
||||
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"
|
||||
# mpv does the playback; we drive it over its JSON IPC Unix socket. mpv runs
|
||||
# as its own systemd service with --input-ipc-server pointed at this path
|
||||
# (install.sh configures this).
|
||||
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
|
||||
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
|
||||
|
||||
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,32 +3,29 @@ import os
|
|||
import threading
|
||||
import time
|
||||
|
||||
from .kodi import KodiConnectionError, KodiError, seconds_from_time
|
||||
from .media import list_video_files
|
||||
from .mpv import MpvConnectionError, MpvError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL = 1.0
|
||||
RECONNECT_INTERVAL = 2.0
|
||||
|
||||
# Kodi's video playlist id (Playlist.GetPlaylists: 0=audio, 1=video, 2=picture).
|
||||
VIDEO_PLAYLIST_ID = 1
|
||||
|
||||
|
||||
class PlayerStateManager:
|
||||
"""Controls Kodi over JSON-RPC and caches its playback state.
|
||||
"""Controls mpv over its JSON IPC socket and caches its playback state.
|
||||
|
||||
Playback is driven through Kodi's own PLAYLIST: playing a file or folder
|
||||
loads the whole folder into Kodi's video playlist and starts it. Kodi 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 Kodi playlist navigation; "keep playing" is
|
||||
Kodi's repeat mode.
|
||||
Playback is driven through mpv's own PLAYLIST: playing a file or folder
|
||||
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):
|
||||
self._kodi = kodi_client
|
||||
def __init__(self, mpv_client, video_extensions):
|
||||
self._mpv = mpv_client
|
||||
self.video_extensions = video_extensions
|
||||
|
||||
self._lock = threading.Lock()
|
||||
|
|
@ -55,65 +52,47 @@ class PlayerStateManager:
|
|||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
# -- background loop (status only; Kodi owns auto-advance) -------------
|
||||
# -- background loop (status only; mpv owns auto-advance) --------------
|
||||
|
||||
def _run(self):
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._poll_once()
|
||||
except KodiConnectionError as exc:
|
||||
log.debug("kodi not reachable: %s", exc)
|
||||
except MpvConnectionError as exc:
|
||||
log.debug("mpv not reachable: %s", exc)
|
||||
self._set_disconnected()
|
||||
time.sleep(RECONNECT_INTERVAL)
|
||||
continue
|
||||
except KodiError as exc:
|
||||
log.warning("kodi poll error: %s", exc)
|
||||
except MpvError as exc:
|
||||
log.warning("mpv poll error: %s", exc)
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
def _set_disconnected(self):
|
||||
with self._lock:
|
||||
self._state["connected"] = False
|
||||
|
||||
def _active_player(self):
|
||||
"""Return the active player dict ({playerid, type}) or None if idle."""
|
||||
players = self._kodi.call("Player.GetActivePlayers")
|
||||
for p in players or []:
|
||||
if p.get("type") in ("video", "audio"):
|
||||
return p
|
||||
return players[0] if players else None
|
||||
|
||||
def _active_player_id(self):
|
||||
player = self._active_player()
|
||||
return player["playerid"] if player else None
|
||||
def _has_media(self):
|
||||
"""True if mpv currently has a file loaded (not idle). Raises
|
||||
MpvConnectionError if mpv is unreachable."""
|
||||
return self._mpv.try_get("path") is not None
|
||||
|
||||
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
|
||||
paused = None
|
||||
|
||||
if player is not None:
|
||||
pid = player["playerid"]
|
||||
props = self._kodi.call(
|
||||
"Player.GetProperties",
|
||||
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
|
||||
if path is not None:
|
||||
filename = os.path.basename(path) or self._mpv.try_get("media-title")
|
||||
position = self._mpv.try_get("time-pos")
|
||||
duration = self._mpv.try_get("duration")
|
||||
paused = bool(self._mpv.try_get("pause", False))
|
||||
|
||||
item = self._kodi.call(
|
||||
"Player.GetItem", playerid=pid, properties=["file"]
|
||||
) 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")
|
||||
volume = self._mpv.try_get("volume")
|
||||
if volume is not None:
|
||||
volume = int(round(volume))
|
||||
|
||||
with self._lock:
|
||||
self._state.update({
|
||||
|
|
@ -135,27 +114,21 @@ class PlayerStateManager:
|
|||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
def _play_playlist(self, files, start_index):
|
||||
"""Load `files` into Kodi's video playlist and start at start_index.
|
||||
Kodi advances through them on its own from here on."""
|
||||
self._kodi.call("Playlist.Clear", playlistid=VIDEO_PLAYLIST_ID)
|
||||
for f in files:
|
||||
self._kodi.call("Playlist.Add", playlistid=VIDEO_PLAYLIST_ID, item={"file": f})
|
||||
self._kodi.call(
|
||||
"Player.Open", item={"playlistid": VIDEO_PLAYLIST_ID, "position": start_index}
|
||||
)
|
||||
"""Load `files` into mpv's playlist (folder order) and start at
|
||||
start_index. mpv advances through them on its own from here on."""
|
||||
# `loadfile ... replace` starts a fresh playlist with the first file;
|
||||
# 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 _apply_repeat(self):
|
||||
"""Set Kodi's repeat mode on the active player to match keep-playing.
|
||||
Retries briefly: right after Player.Open the player may not be active
|
||||
for a beat."""
|
||||
repeat = "all" if self._keep_playing else "off"
|
||||
for _ in range(10):
|
||||
pid = self._active_player_id()
|
||||
if pid is not None:
|
||||
self._kodi.call("Player.SetRepeat", playerid=pid, repeat=repeat)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
"""Set mpv's playlist loop to match keep-playing."""
|
||||
self._mpv.set_property("loop-playlist", "inf" if self._keep_playing else "no")
|
||||
|
||||
# -- public command API (called from Flask routes) ---------------------
|
||||
|
||||
|
|
@ -178,46 +151,31 @@ class PlayerStateManager:
|
|||
self._play_playlist(files, 0)
|
||||
|
||||
def playpause(self):
|
||||
pid = self._active_player_id()
|
||||
if pid is not None:
|
||||
self._kodi.call("Player.PlayPause", playerid=pid)
|
||||
if self._has_media():
|
||||
self._mpv.command("cycle", "pause")
|
||||
|
||||
def next(self):
|
||||
pid = self._active_player_id()
|
||||
if pid is not None:
|
||||
self._kodi.call("Player.GoTo", playerid=pid, to="next")
|
||||
if self._has_media():
|
||||
self._mpv.command("playlist-next", "weak")
|
||||
|
||||
def previous(self):
|
||||
pid = self._active_player_id()
|
||||
if pid is not None:
|
||||
self._kodi.call("Player.GoTo", playerid=pid, to="previous")
|
||||
if self._has_media():
|
||||
self._mpv.command("playlist-prev", "weak")
|
||||
|
||||
def seek(self, offset_seconds):
|
||||
pid = self._active_player_id()
|
||||
if pid is not None:
|
||||
# Kodi takes a relative jump as value={"seconds": N}.
|
||||
self._kodi.call("Player.Seek", playerid=pid, value={"seconds": int(offset_seconds)})
|
||||
if self._has_media():
|
||||
self._mpv.command("seek", int(offset_seconds), "relative")
|
||||
|
||||
def seek_to(self, position_seconds):
|
||||
"""Seek to an absolute position (seconds from the start) -- used by the
|
||||
draggable progress bar."""
|
||||
pid = self._active_player_id()
|
||||
if pid is not None:
|
||||
if self._has_media():
|
||||
pos = max(0, int(position_seconds))
|
||||
self._kodi.call(
|
||||
"Player.Seek",
|
||||
playerid=pid,
|
||||
value={"time": {
|
||||
"hours": pos // 3600,
|
||||
"minutes": (pos % 3600) // 60,
|
||||
"seconds": pos % 60,
|
||||
"milliseconds": 0,
|
||||
}},
|
||||
)
|
||||
self._mpv.command("seek", pos, "absolute")
|
||||
|
||||
def set_volume(self, 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):
|
||||
with self._lock:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from ..kodi import KodiError
|
||||
from ..mpv import MpvError
|
||||
from ..media import PathError, list_directory, resolve_path
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
|
@ -42,7 +42,7 @@ def play():
|
|||
player.play_folder(resolved)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except KodiError as exc:
|
||||
except MpvError as exc:
|
||||
return jsonify({"error": f"player unavailable: {exc}"}), 503
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
|
@ -52,7 +52,7 @@ def play():
|
|||
def playpause():
|
||||
try:
|
||||
current_app.player.playpause()
|
||||
except KodiError as exc:
|
||||
except MpvError as exc:
|
||||
return jsonify({"error": f"player unavailable: {exc}"}), 503
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ def playpause():
|
|||
def next_clip():
|
||||
try:
|
||||
current_app.player.next()
|
||||
except KodiError as exc:
|
||||
except MpvError as exc:
|
||||
return jsonify({"error": f"player unavailable: {exc}"}), 503
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ def next_clip():
|
|||
def previous_clip():
|
||||
try:
|
||||
current_app.player.previous()
|
||||
except KodiError as exc:
|
||||
except MpvError as exc:
|
||||
return jsonify({"error": f"player unavailable: {exc}"}), 503
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ def seek():
|
|||
|
||||
try:
|
||||
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})
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ def seekto():
|
|||
|
||||
try:
|
||||
current_app.player.seek_to(position)
|
||||
except KodiError as exc:
|
||||
except MpvError as exc:
|
||||
return jsonify({"error": f"player unavailable: {exc}"}), 503
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ def volume():
|
|||
|
||||
try:
|
||||
current_app.player.set_volume(value)
|
||||
except KodiError as exc:
|
||||
except MpvError as exc:
|
||||
return jsonify({"error": f"player unavailable: {exc}"}), 503
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@
|
|||
const slider = el("seek-slider");
|
||||
slider.max = data.duration || 0;
|
||||
// Don't fight the user while they're dragging, or right after a seek
|
||||
// (Kodi takes a beat to report the new position).
|
||||
// (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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
[Unit]
|
||||
Description=MediaPi Flask app
|
||||
After=network-online.target mediapi-kodi.service
|
||||
Wants=network-online.target mediapi-kodi.service
|
||||
After=network-online.target mediapi-mpv.service
|
||||
Wants=network-online.target mediapi-mpv.service
|
||||
|
||||
[Service]
|
||||
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
|
||||
38
systemd/mediapi-mpv.service.template
Normal file
38
systemd/mediapi-mpv.service.template
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[Unit]
|
||||
Description=mpv video player (KMS/DRM -- 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}
|
||||
# mpv on DRM/KMS needs: video+render (GPU/DRM), input (remotes/keyboard), audio
|
||||
# (HDMI/ALSA), tty (own the VT). PAMName=login gives it a real logind session so
|
||||
# it can take the seat's DRM/input devices.
|
||||
SupplementaryGroups=video render input audio tty
|
||||
PAMName=login
|
||||
TTYPath=/dev/tty1
|
||||
StandardInput=tty
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Create /run/mediapi (owned by this user) for the IPC socket the app connects to.
|
||||
RuntimeDirectory=mediapi
|
||||
# --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 is hardware-decoded 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
|
||||
Loading…
Reference in a new issue