Replace mpv with Kodi as the player; mediapi drives it over JSON-RPC
Playback moves to Kodi (standalone on GBM/KMS -- the smooth, hardware-decoded LibreELEC path). mediapi becomes a thin remote: it browses media and controls Kodi over its JSON-RPC HTTP API. This drops every mpv/DRM/X-mirror problem (DRM-master exclusivity, gpu-next "export failed" wedges, X-mirror A/V desync, software-decode choppiness) -- none of which had a working single config. - new mediapi/kodi.py (stdlib JSON-RPC client); player.py rewritten to poll and drive Kodi (Player.Open/PlayPause/Seek, Application.SetVolume, Player.GetProperties), same public interface + keep-playing auto-advance - config.py: KODI_* settings replace MPV_SOCKET; __init__ + api_routes updated - delete mpv_ipc.py, the mpv/X units, start-mpv/session scripts - new systemd/mediapi-kodi.service.template (standalone Kodi on tty1) + scripts/configure-kodi.py (headlessly enable Kodi's JSON-RPC web server) - install.sh now bootstraps a BARE Pi OS Lite end to end: apt base packages, installs uv, installs Kodi, enables its web API, configures the AP, installs + starts services (only git need be preinstalled). Auto-seeds .env from the example on first run. - README + .env.example updated for the Kodi architecture Dual HDMI is handled in hardware (external splitter off one port); no software mirror -- see git history for why that can't work smoothly on this Pi. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee72d8b735
commit
afe1ab3e2f
14 changed files with 364 additions and 343 deletions
17
.env.example
17
.env.example
|
|
@ -12,6 +12,15 @@ MEDIAPI_PASSWORD=changeme
|
||||||
MEDIAPI_MEDIA_ROOTS=/localmedia
|
MEDIAPI_MEDIA_ROOTS=/localmedia
|
||||||
MEDIAPI_PORT=8080
|
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
|
||||||
|
|
||||||
# --- System: the Linux user the systemd services run as ---
|
# --- System: the Linux user the systemd services run as ---
|
||||||
# Auto-detected from whoever runs install.sh (id -un) when left unset, so you
|
# Auto-detected from whoever runs install.sh (id -un) when left unset, so you
|
||||||
# normally don't need this. Uncomment only to force a specific user.
|
# normally don't need this. Uncomment only to force a specific user.
|
||||||
|
|
@ -23,11 +32,3 @@ MEDIAPI_AP_SSID=changeme
|
||||||
MEDIAPI_AP_PASSWORD=changeme
|
MEDIAPI_AP_PASSWORD=changeme
|
||||||
MEDIAPI_AP_CONN_NAME=mediapi-ap
|
MEDIAPI_AP_CONN_NAME=mediapi-ap
|
||||||
|
|
||||||
# --- Advanced (leave unset unless you know you need them) ---
|
|
||||||
# Where the mpv IPC socket + runtime dir live. Defaults below are correct for
|
|
||||||
# the shipped systemd unit, which hardcodes RuntimeDirectory=mediapi (=/run/mediapi).
|
|
||||||
# If you override MEDIAPI_RUNTIME_DIR you MUST also edit
|
|
||||||
# systemd/mediapi-mpv.service.template's RuntimeDirectory to match, or the dir
|
|
||||||
# won't be created at boot. Most deployments should leave both commented out.
|
|
||||||
#MEDIAPI_RUNTIME_DIR=/run/mediapi
|
|
||||||
#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock
|
|
||||||
|
|
|
||||||
84
README.md
84
README.md
|
|
@ -110,46 +110,45 @@ Keep values simple (no spaces / shell-special characters).
|
||||||
|
|
||||||
## Setup (mediapi app)
|
## Setup (mediapi app)
|
||||||
|
|
||||||
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
|
Playback is done by **Kodi** (`mediapi-kodi` unit), running standalone on
|
||||||
player controlled over its JSON IPC socket. mpv renders video to HDMI; the
|
GBM/KMS straight on the hardware — the same smooth, hardware-decoded path as
|
||||||
phone browser only shows metadata/controls, never the video image itself.
|
LibreELEC, no desktop. mediapi itself is a small Flask app (`mediapi/`) that is
|
||||||
|
just the phone-facing **remote**: it browses the media folders and drives Kodi
|
||||||
|
over its **JSON-RPC HTTP API** (`Player.Open`, `Player.PlayPause`,
|
||||||
|
`Player.Seek`, `Application.SetVolume`, `Player.GetProperties`). The phone
|
||||||
|
browser only shows metadata/controls, never the video image itself.
|
||||||
|
|
||||||
**Dual-HDMI mirroring.** The Pi 4's two HDMI connectors share a single vc4 DRM
|
Kodi handles decode, HDMI audio and display; mediapi keeps a background poll of
|
||||||
card, and DRM *master* is exclusive per card — so two independent mpv processes
|
Kodi's player state (position/duration/pause/volume) and drives "keep playing"
|
||||||
can't both drive a screen (the second gets `Failed to acquire DRM master`).
|
auto-advance through a folder. `install.sh` installs Kodi, autostarts it, and
|
||||||
Instead the `mediapi-mpv` unit runs a minimal **X server** via `xinit`
|
enables its web server (JSON-RPC) headlessly by seeding `guisettings.xml` (see
|
||||||
(`scripts/mediapi-session.sh`): X is the one DRM master, `xrandr --same-as`
|
`scripts/configure-kodi.py`). Set the Kodi port/credentials in `.env`
|
||||||
clones the first output's framebuffer onto every other connected HDMI output,
|
(`MEDIAPI_KODI_*`); keep the port different from `MEDIAPI_PORT`.
|
||||||
and a **single** fullscreen mpv renders into it — so the same frames appear on
|
|
||||||
both screens, decoded once. mpv carries audio and the app's only IPC socket
|
> **Dual HDMI:** the Pi 4 can't cleanly mirror both HDMI ports in software
|
||||||
(`/run/mediapi/mpv.sock`); `--hwdec=v4l2m2m-copy` uses the Pi's hardware H.264/
|
> (DRM master is exclusive per card; the X-mirror path can't keep up). Drive
|
||||||
HEVC decoder (~⅓ the CPU of software decoding), falling back to software for
|
> both car screens from one HDMI port through an external powered HDMI splitter.
|
||||||
codecs it can't handle. The session waits for connectors to probe as connected
|
|
||||||
before mirroring, so a cold-boot HDMI race doesn't drop it to one screen. One
|
|
||||||
screen attached → just that screen, no config needed.
|
|
||||||
|
|
||||||
### First-time install (on the Pi)
|
### First-time install (on the Pi)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# system deps: mpv for playback (install.sh installs the minimal X server itself)
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y mpv
|
|
||||||
|
|
||||||
# install uv (Python package/venv manager) if not already present
|
# install uv (Python package/venv manager) if not already present
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
|
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
|
||||||
|
|
||||||
# create your .env (see Configuration above)
|
# create your .env (see Configuration above)
|
||||||
cp .env.example .env && $EDITOR .env
|
cp .env.example .env && $EDITOR .env
|
||||||
|
|
||||||
# deploy the currently-checked-out ref: syncs deps, adds the service user to
|
# deploy the currently-checked-out ref: installs Kodi + Python deps, enables
|
||||||
# the video+render groups (GPU/DRM access for HDMI), configures the AP,
|
# Kodi's JSON-RPC web server, configures the AP, installs+starts the services.
|
||||||
# installs+starts the services. Run as your normal user -- NOT with sudo, or
|
# Run as your normal user -- NOT with sudo, or the services get rendered to run
|
||||||
# the services get rendered to run as root.
|
# as root.
|
||||||
./install.sh
|
./install.sh
|
||||||
|
|
||||||
# verify
|
# verify
|
||||||
systemctl status mediapi-mpv mediapi-app
|
systemctl status mediapi-kodi mediapi-app
|
||||||
ls -l /run/mediapi/mpv.sock
|
curl -s "http://kodi:$(grep KODI_PASSWORD .env|cut -d= -f2)@127.0.0.1:8090/jsonrpc" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' # -> {"result":"pong"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
|
Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
|
||||||
|
|
@ -193,25 +192,20 @@ cd ~/mediapi && ./install.sh v1.2.0
|
||||||
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
|
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes / things to double check on the actual hardware (couldn't be verified
|
Notes / things to double check on the actual hardware:
|
||||||
from a dev machine):
|
|
||||||
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
|
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
|
||||||
on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
|
on current Pi4 images (needed for KMS output) — worth a quick check.
|
||||||
* Mirroring clones every HDMI output that reads `connected` at session start
|
* Kodi runs standalone on GBM as the `mediapi-kodi` service (on `tty1`). If the
|
||||||
onto the first. The session waits for connectors to probe (cold-boot race),
|
screen stays on the console, check `sudo journalctl -u mediapi-kodi`; Kodi's
|
||||||
then runs `xrandr --same-as`. See what it did with
|
own log is at `~/.kodi/temp/kodi.log`. It needs the service user in the
|
||||||
`sudo journalctl -u mediapi-mpv | grep mediapi-session`; inspect the outputs
|
`video render input audio tty` groups (install.sh adds them).
|
||||||
live with `DISPLAY=:0 xrandr` (as the service user). Both screens should share
|
* If the web API is unreachable (`Connection refused` on
|
||||||
a common resolution; if they differ, `xrandr` clones at the first output's
|
`:${MEDIAPI_KODI_PORT}`), the web server didn't get enabled. Kodi rewrites
|
||||||
mode. Confirm the one socket: `ls -l /run/mediapi/mpv.sock`.
|
`guisettings.xml` on exit, so re-run `install.sh` (it stops Kodi, seeds the
|
||||||
* Non-root X requires `/etc/X11/Xwrapper.config` with `allowed_users=anybody`
|
setting via `scripts/configure-kodi.py`, and restarts), or toggle
|
||||||
(install.sh writes it). If the service crash-loops with `Could not create
|
Settings → Services → Control → *Allow remote control via HTTP* in the Kodi
|
||||||
server lock file: /tmp/.X0-lock`, a previous X died uncleanly — remove
|
GUI once. Verify with `JSONRPC.Ping` (see install snippet above).
|
||||||
`/tmp/.X0-lock` (and `/tmp/.X11-unix/X0`) and restart. The X log is at
|
* Kodi handles HDMI audio itself (Settings → System → Audio). If there's no
|
||||||
`~/.local/share/xorg/Xorg.0.log`.
|
sound, set the audio output device to the HDMI sink there.
|
||||||
* If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA
|
|
||||||
device name (usually `vc4-hdmi`) and add e.g.
|
|
||||||
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
|
|
||||||
run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
|
|
||||||
|
|
||||||
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`
|
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`
|
||||||
|
|
|
||||||
81
install.sh
81
install.sh
|
|
@ -41,9 +41,12 @@ DEPLOYED_REF_FILE="instance/deployed_ref"
|
||||||
TARGET_REF="${1:-}"
|
TARGET_REF="${1:-}"
|
||||||
|
|
||||||
# --- load config ----------------------------------------------------
|
# --- load config ----------------------------------------------------
|
||||||
|
# First run on a fresh Pi may have no .env yet -- seed it from the example so the
|
||||||
|
# script can proceed, but make it loud: the defaults ship an insecure AP.
|
||||||
if [[ ! -f .env ]]; then
|
if [[ ! -f .env ]]; then
|
||||||
echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2
|
echo "WARNING: .env not found -- creating it from .env.example." >&2
|
||||||
exit 1
|
echo " Edit .env with your real AP/login/Kodi passwords, then re-run." >&2
|
||||||
|
cp .env.example .env
|
||||||
fi
|
fi
|
||||||
set -a
|
set -a
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
|
|
@ -52,7 +55,7 @@ set +a
|
||||||
|
|
||||||
MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}"
|
MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}"
|
||||||
MEDIAPI_PORT="${MEDIAPI_PORT:-8080}"
|
MEDIAPI_PORT="${MEDIAPI_PORT:-8080}"
|
||||||
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
UV="" # set by bootstrap_system once uv is installed
|
||||||
|
|
||||||
# --- 0. refuse to run on a read-only overlay ------------------------
|
# --- 0. refuse to run on a read-only overlay ------------------------
|
||||||
if findmnt -no FSTYPE / | grep -q overlay; then
|
if findmnt -no FSTYPE / | grep -q overlay; then
|
||||||
|
|
@ -90,9 +93,30 @@ if [[ -n "$PREV_GOOD" && "$PREV_GOOD" != "$CURRENT_REF" ]]; then
|
||||||
echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)"
|
echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- system bootstrap (fresh Pi: assume ONLY git is installed) -------
|
||||||
|
# Installs everything else the deploy needs so a bare Raspberry Pi OS Lite goes
|
||||||
|
# from "git clone + ./install.sh" to a working player. NetworkManager (nmcli)
|
||||||
|
# and raspi-config already ship on Pi OS. Sets the global UV path afterwards.
|
||||||
|
bootstrap_system() {
|
||||||
|
echo "==> apt update + base packages (curl, python3) ..."
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates python3
|
||||||
|
|
||||||
|
if ! command -v uv >/dev/null 2>&1 && [[ ! -x "$HOME/.local/bin/uv" ]]; then
|
||||||
|
echo "==> Installing uv ..."
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
fi
|
||||||
|
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
||||||
|
if [[ ! -x "$UV" ]]; then
|
||||||
|
echo "ERROR: uv is still not available at '$UV' after install." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --- render + install systemd units (used again on rollback) --------
|
# --- render + install systemd units (used again on rollback) --------
|
||||||
install_units() {
|
install_units() {
|
||||||
for unit in mediapi-mpv mediapi-app; 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" \
|
||||||
|
|
@ -116,27 +140,19 @@ deploy_current() {
|
||||||
chmod 600 instance/secret_key
|
chmod 600 instance/secret_key
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The mpv service runs as MEDIAPI_USER and needs the video+render groups to
|
# Kodi (the player) runs as MEDIAPI_USER on GBM/KMS and needs these groups to
|
||||||
# reach the GPU/DRM devices for HDMI output, plus tty+input to own the VT and
|
# reach the GPU/DRM, audio, input and console devices. Idempotent; systemd
|
||||||
# read input under X. Idempotent; systemd picks up the new membership when it
|
# picks up the new membership when it (re)starts the service below.
|
||||||
# (re)starts the service below, so no logout needed.
|
|
||||||
if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then
|
if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then
|
||||||
echo "==> Adding '${MEDIAPI_USER}' to video,render,input,tty groups ..."
|
echo "==> Adding '${MEDIAPI_USER}' to video,render,input,audio,tty groups ..."
|
||||||
sudo usermod -aG video,render,input,tty "${MEDIAPI_USER}"
|
sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The player runs a minimal X server so a single mpv can be mirrored across
|
# Install Kodi (the actual media player -- mediapi just drives it via JSON-RPC).
|
||||||
# both HDMI outputs (one DRM master; two separate mpv on the shared vc4 card
|
if ! command -v kodi-standalone >/dev/null 2>&1; then
|
||||||
# cannot -- see scripts/mediapi-session.sh). Install just the X server + xinit
|
echo "==> Installing Kodi ..."
|
||||||
# + xrandr, and allow the non-root service user to start X on its VT.
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends kodi
|
||||||
if ! command -v Xorg >/dev/null 2>&1; then
|
|
||||||
echo "==> Installing minimal X server (xserver-xorg-core, xinit, xrandr) ..."
|
|
||||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
||||||
xserver-xorg-core xinit x11-xserver-utils
|
|
||||||
fi
|
fi
|
||||||
echo "==> Configuring /etc/X11/Xwrapper.config (allow non-root X on the VT) ..."
|
|
||||||
printf 'allowed_users=anybody\nneeds_root_rights=yes\n' \
|
|
||||||
| sudo tee /etc/X11/Xwrapper.config >/dev/null
|
|
||||||
|
|
||||||
echo "==> Applying WiFi country + AP config ..."
|
echo "==> Applying WiFi country + AP config ..."
|
||||||
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
|
sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}"
|
||||||
|
|
@ -156,10 +172,24 @@ deploy_current() {
|
||||||
ipv4.method shared
|
ipv4.method shared
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Installing systemd units + restarting services ..."
|
echo "==> Installing systemd units ..."
|
||||||
install_units
|
install_units
|
||||||
sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true
|
sudo systemctl enable mediapi-kodi mediapi-app >/dev/null 2>&1 || true
|
||||||
sudo systemctl restart mediapi-mpv
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
echo "==> Starting services ..."
|
||||||
|
sudo systemctl restart mediapi-kodi
|
||||||
sudo systemctl restart mediapi-app
|
sudo systemctl restart mediapi-app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,6 +205,7 @@ app_healthy() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 2-6. deploy ----------------------------------------------------
|
# --- 2-6. deploy ----------------------------------------------------
|
||||||
|
bootstrap_system
|
||||||
deploy_current
|
deploy_current
|
||||||
|
|
||||||
# --- 7. health check + rollback ------------------------------------
|
# --- 7. health check + rollback ------------------------------------
|
||||||
|
|
@ -182,7 +213,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 || true
|
systemctl --no-pager --lines=0 status mediapi-kodi mediapi-app || true
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +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 .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
|
||||||
|
|
@ -18,10 +19,12 @@ def create_app():
|
||||||
|
|
||||||
register_auth_gate(app)
|
register_auth_gate(app)
|
||||||
|
|
||||||
app.player = PlayerStateManager(
|
kodi = KodiClient(
|
||||||
app.config["MPV_SOCKET"],
|
app.config["KODI_URL"],
|
||||||
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,9 +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
|
||||||
]
|
]
|
||||||
# A single mpv drives every HDMI output -- the X session mirrors the screens
|
# Kodi does the playback; we drive it over its JSON-RPC HTTP endpoint.
|
||||||
# with xrandr (see scripts/mediapi-session.sh) -- so there is one socket.
|
# Kodi's web server must be enabled (install.sh configures this) and should
|
||||||
MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock")
|
# 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"
|
||||||
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
73
mediapi/kodi.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
|
|
||||||
|
|
||||||
class MpvIPCError(Exception):
|
|
||||||
"""Base class for all mpv IPC errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class MpvConnectionError(MpvIPCError):
|
|
||||||
"""The underlying socket is broken; caller should reconnect."""
|
|
||||||
|
|
||||||
|
|
||||||
class MpvCommandError(MpvIPCError):
|
|
||||||
"""mpv responded but rejected the command (e.g. a property that's
|
|
||||||
legitimately unavailable while idle, like time-pos with nothing loaded).
|
|
||||||
The connection itself is fine."""
|
|
||||||
|
|
||||||
|
|
||||||
class MpvIPCClient:
|
|
||||||
"""Minimal client for mpv's JSON IPC protocol over a unix domain socket.
|
|
||||||
|
|
||||||
One request in flight at a time (guarded by a lock) since responses on
|
|
||||||
the socket aren't tagged with a request id in a way we bother matching --
|
|
||||||
we just send a command and read the next response line.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, socket_path):
|
|
||||||
self.socket_path = socket_path
|
|
||||||
self._sock = None
|
|
||||||
self._file = None
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
sock.settimeout(2)
|
|
||||||
sock.connect(self.socket_path)
|
|
||||||
self._sock = sock
|
|
||||||
self._file = sock.makefile("rwb")
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self._sock is not None:
|
|
||||||
try:
|
|
||||||
self._sock.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._sock = None
|
|
||||||
self._file = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def connected(self):
|
|
||||||
return self._sock is not None
|
|
||||||
|
|
||||||
def command(self, *args):
|
|
||||||
"""Send an mpv command, return its "data" field. Raises MpvIPCError
|
|
||||||
on failure or if not connected -- caller (PlayerStateManager) is
|
|
||||||
responsible for reconnect logic."""
|
|
||||||
if not self.connected:
|
|
||||||
raise MpvConnectionError("not connected")
|
|
||||||
|
|
||||||
payload = json.dumps({"command": list(args)}) + "\n"
|
|
||||||
with self._lock:
|
|
||||||
try:
|
|
||||||
self._file.write(payload.encode("utf-8"))
|
|
||||||
self._file.flush()
|
|
||||||
while True:
|
|
||||||
line = self._file.readline()
|
|
||||||
if not line:
|
|
||||||
raise MpvConnectionError("socket closed")
|
|
||||||
msg = json.loads(line)
|
|
||||||
# skip async event notifications, wait for the command reply
|
|
||||||
if "event" in msg:
|
|
||||||
continue
|
|
||||||
if msg.get("error") != "success":
|
|
||||||
raise MpvCommandError(msg.get("error", "unknown error"))
|
|
||||||
return msg.get("data")
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
self.close()
|
|
||||||
raise MpvConnectionError(str(exc)) from exc
|
|
||||||
|
|
||||||
def get_property(self, name):
|
|
||||||
return self.command("get_property", name)
|
|
||||||
|
|
||||||
def set_property(self, name, value):
|
|
||||||
return self.command("set_property", name, value)
|
|
||||||
|
|
@ -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_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -13,18 +13,16 @@ RECONNECT_INTERVAL = 2.0
|
||||||
|
|
||||||
|
|
||||||
class PlayerStateManager:
|
class PlayerStateManager:
|
||||||
"""Owns the single connection to mpv's IPC socket. A background thread
|
"""Owns control of Kodi. A background thread polls Kodi's JSON-RPC for
|
||||||
polls mpv for playback state and drives "keep playing" auto-advance;
|
playback state and drives "keep playing" auto-advance; Flask request
|
||||||
Flask request handlers only ever read the cached snapshot or send a
|
handlers only ever read the cached snapshot or send a command through this
|
||||||
command through this class -- they never touch the socket directly."""
|
class. Kodi itself does the decoding/output -- mediapi is just the remote.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, socket_path, video_extensions):
|
def __init__(self, kodi_client, video_extensions):
|
||||||
self.socket_path = socket_path
|
self._kodi = kodi_client
|
||||||
self.video_extensions = video_extensions
|
self.video_extensions = video_extensions
|
||||||
# A single mpv drives every HDMI output (the X session mirrors the
|
|
||||||
# screens with xrandr -- see scripts/mediapi-session.sh), so there is
|
|
||||||
# exactly one socket to talk to.
|
|
||||||
self._client = MpvIPCClient(socket_path)
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
|
@ -38,10 +36,11 @@ class PlayerStateManager:
|
||||||
"keep_playing": False,
|
"keep_playing": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auto-advance queue (the containing folder), same model as before.
|
||||||
self._queue_folder = None
|
self._queue_folder = None
|
||||||
self._queue_files = []
|
self._queue_files = []
|
||||||
self._queue_index = -1
|
self._queue_index = -1
|
||||||
self._prev_idle = True
|
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)
|
||||||
|
|
@ -54,52 +53,63 @@ class PlayerStateManager:
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
if not self._client.connected:
|
|
||||||
try:
|
|
||||||
self._client.connect()
|
|
||||||
log.info("connected to mpv socket at %s", self.socket_path)
|
|
||||||
except OSError:
|
|
||||||
self._set_disconnected()
|
|
||||||
time.sleep(RECONNECT_INTERVAL)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._poll_once()
|
self._poll_once()
|
||||||
except MpvConnectionError as exc:
|
except KodiConnectionError as exc:
|
||||||
log.warning("lost connection to mpv: %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 KodiError as 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 _get_property_safe(self, name):
|
def _active_player(self):
|
||||||
"""Like client.get_property, but treats "property unavailable"
|
"""Return the active player dict ({playerid, type}) or None if idle."""
|
||||||
(normal for time-pos/duration/filename while mpv is idle) as None
|
players = self._kodi.call("Player.GetActivePlayers")
|
||||||
instead of a fatal error -- only a real MpvConnectionError should
|
for p in players or []:
|
||||||
tear down the connection."""
|
if p.get("type") in ("video", "audio"):
|
||||||
try:
|
return p
|
||||||
return self._client.get_property(name)
|
return players[0] if players else None
|
||||||
except MpvCommandError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _poll_once(self):
|
def _poll_once(self):
|
||||||
idle = bool(self._get_property_safe("idle-active"))
|
player = self._active_player() # raises KodiConnectionError if down
|
||||||
filename = self._get_property_safe("filename")
|
|
||||||
position = self._get_property_safe("time-pos")
|
|
||||||
duration = self._get_property_safe("duration")
|
|
||||||
paused = self._get_property_safe("pause")
|
|
||||||
volume = self._get_property_safe("volume")
|
|
||||||
|
|
||||||
if idle and not self._prev_idle:
|
filename = position = duration = None
|
||||||
|
paused = None
|
||||||
|
playing = player is not 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
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Auto-advance: playback just ended (was playing, now nothing active).
|
||||||
|
if self._prev_playing and not playing:
|
||||||
self._maybe_advance()
|
self._maybe_advance()
|
||||||
idle = self._get_property_safe("idle-active")
|
self._prev_playing = playing
|
||||||
|
|
||||||
self._prev_idle = bool(idle)
|
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._state.update({
|
self._state.update({
|
||||||
|
|
@ -112,7 +122,7 @@ class PlayerStateManager:
|
||||||
})
|
})
|
||||||
|
|
||||||
def _maybe_advance(self):
|
def _maybe_advance(self):
|
||||||
"""Called from the poll loop when mpv just went idle. If keep-playing
|
"""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."""
|
is on and there's a next file in the current folder queue, load it."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
keep_playing = self._state["keep_playing"]
|
keep_playing = self._state["keep_playing"]
|
||||||
|
|
@ -128,31 +138,40 @@ class PlayerStateManager:
|
||||||
|
|
||||||
if next_file:
|
if next_file:
|
||||||
try:
|
try:
|
||||||
self._client.command("loadfile", next_file, "replace")
|
self._open(next_file)
|
||||||
except MpvIPCError as exc:
|
except KodiError as exc:
|
||||||
log.warning("auto-advance loadfile failed: %s", exc)
|
log.warning("auto-advance failed: %s", exc)
|
||||||
|
|
||||||
# -- public read API ---------------------------------------------------
|
# -- public read API ---------------------------------------------------
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = dict(self._state)
|
return dict(self._state)
|
||||||
state["keep_playing"] = self._state["keep_playing"]
|
|
||||||
return state
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _open(self, path):
|
||||||
|
self._kodi.call("Player.Open", item={"file": path})
|
||||||
|
# An Open means playback is starting, so treat the next idle as a real
|
||||||
|
# end-of-file (not the brief gap between stop and start).
|
||||||
|
self._prev_playing = True
|
||||||
|
|
||||||
|
def _active_player_id(self):
|
||||||
|
player = self._active_player()
|
||||||
|
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):
|
||||||
self._client.command("loadfile", path, "replace")
|
self._open(path)
|
||||||
# Build the auto-advance queue from the containing folder, positioned
|
# Build the auto-advance queue from the containing folder, positioned at
|
||||||
# at the file just started -- so "keep playing" continues through the
|
# the file just started -- so "keep playing" continues through the rest
|
||||||
# rest of the folder even when you start from the middle.
|
# of the folder even when you start from the middle.
|
||||||
folder = os.path.dirname(path)
|
folder = os.path.dirname(path)
|
||||||
files = list_video_files(folder, self.video_extensions)
|
files = list_video_files(folder, self.video_extensions)
|
||||||
try:
|
try:
|
||||||
index = files.index(path)
|
index = files.index(path)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# played file isn't in the folder listing (unusual) -- queue just it
|
|
||||||
files = [path]
|
files = [path]
|
||||||
index = 0
|
index = 0
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -164,21 +183,26 @@ class PlayerStateManager:
|
||||||
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._client.command("loadfile", files[0], "replace")
|
self._open(files[0])
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._queue_folder = folder_path
|
self._queue_folder = folder_path
|
||||||
self._queue_files = files
|
self._queue_files = files
|
||||||
self._queue_index = 0
|
self._queue_index = 0
|
||||||
|
|
||||||
def playpause(self):
|
def playpause(self):
|
||||||
self._client.command("cycle", "pause")
|
pid = self._active_player_id()
|
||||||
|
if pid is not None:
|
||||||
|
self._kodi.call("Player.PlayPause", playerid=pid)
|
||||||
|
|
||||||
def seek(self, offset_seconds):
|
def seek(self, offset_seconds):
|
||||||
self._client.command("seek", offset_seconds, "relative")
|
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)})
|
||||||
|
|
||||||
def set_volume(self, value):
|
def set_volume(self, value):
|
||||||
value = max(0, min(100, value))
|
value = max(0, min(100, value))
|
||||||
self._client.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:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from flask import Blueprint, current_app, jsonify, request
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
|
||||||
|
from ..kodi import KodiError
|
||||||
from ..media import PathError, list_directory, resolve_path
|
from ..media import PathError, list_directory, resolve_path
|
||||||
from ..mpv_ipc import MpvIPCError
|
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ def play():
|
||||||
player.play_folder(resolved)
|
player.play_folder(resolved)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
except MpvIPCError 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,7 +52,7 @@ def play():
|
||||||
def playpause():
|
def playpause():
|
||||||
try:
|
try:
|
||||||
current_app.player.playpause()
|
current_app.player.playpause()
|
||||||
except MpvIPCError 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})
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ def seek():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.player.seek(offset)
|
current_app.player.seek(offset)
|
||||||
except MpvIPCError 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})
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ def volume():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.player.set_volume(value)
|
current_app.player.set_volume(value)
|
||||||
except MpvIPCError 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})
|
||||||
|
|
||||||
|
|
|
||||||
58
scripts/configure-kodi.py
Normal file
58
scripts/configure-kodi.py
Normal 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()
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
#
|
|
||||||
# mediapi X session -- the X client launched by xinit from mediapi-mpv.service.
|
|
||||||
#
|
|
||||||
# Mirrors every extra HDMI output onto the first, then runs ONE fullscreen mpv.
|
|
||||||
#
|
|
||||||
# Why an X server at all (we used to run mpv straight on DRM/KMS for fast boot):
|
|
||||||
# DRM "master" is exclusive per card. The Pi 4's two HDMI connectors live on a
|
|
||||||
# single vc4 card, so two independent mpv processes cannot both modeset -- the
|
|
||||||
# second gets "Failed to acquire DRM master: Permission denied" and shows
|
|
||||||
# nothing. A single X server is the one DRM master and drives BOTH connectors;
|
|
||||||
# `xrandr --same-as` clones the first output's framebuffer onto the others, so
|
|
||||||
# one mpv appears on every screen. See git history for the full diagnosis.
|
|
||||||
#
|
|
||||||
set -u
|
|
||||||
|
|
||||||
RUNTIME_DIR="${MEDIAPI_RUNTIME_DIR:-/run/mediapi}"
|
|
||||||
SOCKET="${MEDIAPI_MPV_SOCKET:-$RUNTIME_DIR/mpv.sock}"
|
|
||||||
|
|
||||||
# Always-on car display: never blank or DPMS-off.
|
|
||||||
xset -dpms 2>/dev/null || true
|
|
||||||
xset s off 2>/dev/null || true
|
|
||||||
|
|
||||||
connected() { xrandr --query 2>/dev/null | awk '/ connected/{print $1}'; }
|
|
||||||
|
|
||||||
# Cold-boot race: the second HDMI connector may not be probed as "connected"
|
|
||||||
# when X starts. Wait for connected outputs to appear, then let the set settle
|
|
||||||
# so a screen that lights up a beat later is still mirrored (bounded so a
|
|
||||||
# genuinely single-screen setup doesn't hang the boot).
|
|
||||||
deadline=$(( $(date +%s) + 30 ))
|
|
||||||
outs="$(connected)"
|
|
||||||
while [ -z "$outs" ] && [ "$(date +%s)" -lt "$deadline" ]; do
|
|
||||||
sleep 1
|
|
||||||
outs="$(connected)"
|
|
||||||
done
|
|
||||||
|
|
||||||
stable=0
|
|
||||||
settle_end=$(( $(date +%s) + 8 ))
|
|
||||||
while [ "$(date +%s)" -lt "$settle_end" ]; do
|
|
||||||
sleep 1
|
|
||||||
cur="$(connected)"
|
|
||||||
if [ "$cur" != "$outs" ]; then
|
|
||||||
outs="$cur"
|
|
||||||
stable=0
|
|
||||||
else
|
|
||||||
stable=$(( stable + 1 ))
|
|
||||||
[ "$stable" -ge 2 ] && break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
primary=""
|
|
||||||
rest=""
|
|
||||||
for o in $outs; do
|
|
||||||
if [ -z "$primary" ]; then primary="$o"; else rest="$rest $o"; fi
|
|
||||||
done
|
|
||||||
echo "mediapi-session: connected outputs:$outs (primary=${primary:-none})"
|
|
||||||
|
|
||||||
for o in $rest; do
|
|
||||||
echo "mediapi-session: mirroring $o onto $primary"
|
|
||||||
xrandr --output "$o" --same-as "$primary" || true
|
|
||||||
done
|
|
||||||
|
|
||||||
# Single mpv, on X (so one DRM master drives every mirrored connector). Carries
|
|
||||||
# audio and the app's IPC socket; the app talks only to this one socket now.
|
|
||||||
# hwdec=v4l2m2m-copy uses the Pi's hardware H.264/HEVC decoder (falls back to
|
|
||||||
# software for codecs it can't handle). Roughly a third of the CPU of software
|
|
||||||
# decoding 1080p -- important for an always-on player in a warm car. The single
|
|
||||||
# mpv has the HW decoder to itself now, so there's no contention.
|
|
||||||
exec mpv \
|
|
||||||
--idle=yes \
|
|
||||||
--vo=gpu-next \
|
|
||||||
--gpu-context=x11egl \
|
|
||||||
--hwdec=v4l2m2m-copy \
|
|
||||||
--fullscreen \
|
|
||||||
--no-terminal \
|
|
||||||
--keep-open=no \
|
|
||||||
--input-ipc-server="$SOCKET" \
|
|
||||||
--ao=alsa
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
25
systemd/mediapi-kodi.service.template
Normal file
25
systemd/mediapi-kodi.service.template
Normal 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
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=mediapi player (X kiosk: one mpv mirrored across all HDMI outputs)
|
|
||||||
# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist before
|
|
||||||
# X tries to grab them. Without this a cold-boot race can leave X unable to find
|
|
||||||
# a screen. (systemd-udev-settle is deprecated but still the simplest reliable
|
|
||||||
# gate here; the session script additionally waits for connectors to appear.)
|
|
||||||
After=local-fs.target systemd-udev-settle.service
|
|
||||||
Wants=systemd-udev-settle.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${MEDIAPI_USER}
|
|
||||||
# video+render: GPU/DRM access. input: evdev under X. tty: own the VT for X.
|
|
||||||
SupplementaryGroups=video render input tty
|
|
||||||
# A controlling tty so the X server can take over VT 7.
|
|
||||||
TTYPath=/dev/tty7
|
|
||||||
StandardInput=tty
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
RuntimeDirectory=mediapi
|
|
||||||
RuntimeDirectoryMode=0770
|
|
||||||
# xinit starts the X server on VT 7 and runs the session script as its client.
|
|
||||||
# The session mirrors the HDMI outputs (single DRM master) and execs one mpv.
|
|
||||||
# Non-root X is permitted via /etc/X11/Xwrapper.config (installed by install.sh).
|
|
||||||
ExecStart=/usr/bin/xinit ${PROJECT_DIR}/scripts/mediapi-session.sh -- :0 vt7 -nolisten tcp -keeptty
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
Loading…
Reference in a new issue