Compare commits
12
Commits
origin
..
c2544c44c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2544c44c7 | ||
|
|
ec826bb8f3 | ||
|
|
270ede8b77 | ||
|
|
5e81403be3 | ||
|
|
72cc66df92 | ||
|
|
63ea314b18 | ||
|
|
4e185b2e1c | ||
|
|
3ee7f94d72 | ||
|
|
8b3bdae8a0 | ||
|
|
45083207b4 | ||
|
|
3ff9ba4637 | ||
|
|
9091f27283 |
@@ -0,0 +1,21 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-*
|
||||||
|
playlists.yaml
|
||||||
|
playlists.yaml.pre-v2.*.bak
|
||||||
|
controller-data/
|
||||||
|
data/
|
||||||
|
cookies*.txt
|
||||||
|
secrets/
|
||||||
|
tests/
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Copy to .env, keep it private, and replace every example path/value.
|
||||||
|
FLASK_SECRET_KEY=generate-a-long-random-secret
|
||||||
|
CONTROLLER_DATA_DIR=/srv/video-player-controller/data
|
||||||
|
YTPLAYER_MEDIA_DIR=/mnt/Media/YTPlayer
|
||||||
|
CONTROLLER_SECRETS_DIR=/srv/video-player-controller/secrets
|
||||||
|
|
||||||
|
MQTT_HOST=10.40.0.2
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
MQTT_CLIENT_ID=yt-controller
|
||||||
|
CONTROLLER_WEB_PORT=7531
|
||||||
|
|
||||||
|
# Match the owner of the host media and data directories.
|
||||||
|
CONTROLLER_UID=1000
|
||||||
|
CONTROLLER_GID=1000
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-*
|
||||||
|
controller-data/
|
||||||
|
data/
|
||||||
|
playlists.yaml.pre-v2.*.bak
|
||||||
|
|
||||||
|
cookies*.txt
|
||||||
|
secrets/
|
||||||
+39
-23
@@ -1,32 +1,48 @@
|
|||||||
FROM python:3.12-slim
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
# System deps: ffmpeg for muxing, nodejs for yt-dlp JS runtime, ca-certs for TLS
|
# yt-dlp requires Node 22+ for current YouTube JavaScript challenges. Copy
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# only the runtime binary into the smaller Python/ffmpeg application image.
|
||||||
ffmpeg \
|
FROM node:22.23.1-bookworm-slim AS javascript-runtime
|
||||||
nodejs \
|
|
||||||
ca-certificates \
|
FROM python:3.12-slim-bookworm
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
ARG CONTROLLER_UID=1000
|
||||||
|
ARG CONTROLLER_GID=1000
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
HOME=/tmp/controller-home \
|
||||||
|
XDG_CACHE_HOME=/tmp/controller-cache \
|
||||||
|
PORT=7531
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libatomic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --gid "${CONTROLLER_GID}" controller \
|
||||||
|
&& useradd --uid "${CONTROLLER_UID}" --gid controller --create-home \
|
||||||
|
--shell /usr/sbin/nologin controller \
|
||||||
|
&& install -d -o controller -g controller /app /controller-data /mnt/Media/YTPlayer
|
||||||
|
|
||||||
|
COPY --from=javascript-runtime /usr/local/bin/node /usr/local/bin/node
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt ./requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN python -m pip install --requirement requirements.txt \
|
||||||
|
&& node --version \
|
||||||
|
&& python -c "from yt_dlp.utils._jsruntime import NodeJsRuntime; info = NodeJsRuntime().info; assert info and info.supported, info" \
|
||||||
|
&& ffmpeg -version
|
||||||
|
|
||||||
# Optional but strongly recommended: keep yt-dlp fresh (only if installed via pip)
|
COPY --chown=controller:controller controller ./controller
|
||||||
RUN python -m pip install --no-cache-dir -U yt-dlp
|
COPY --chown=controller:controller app.py controller_core.py wsgi.py gunicorn.conf.py config.yaml ./
|
||||||
|
|
||||||
# Make yt-dlp defaults persistent (so your subprocess call doesn't need flags)
|
USER controller:controller
|
||||||
# 1) tell yt-dlp to use node as JS runtime
|
|
||||||
# 2) (optional) force android client to dodge SABR weirdness more often
|
|
||||||
RUN mkdir -p /etc/yt-dlp && printf "%s\n" \
|
|
||||||
"--js-runtimes node" \
|
|
||||||
"--extractor-args youtube:player_client=android" \
|
|
||||||
> /etc/yt-dlp.conf
|
|
||||||
|
|
||||||
COPY app.py ./app.py
|
EXPOSE 7531
|
||||||
COPY controller_core.py ./controller_core.py
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7531/healthz', timeout=3).read()"]
|
||||||
|
|
||||||
EXPOSE 8000
|
CMD ["gunicorn", "--config", "gunicorn.conf.py", "wsgi:application"]
|
||||||
CMD ["python", "app.py"]
|
|
||||||
|
|||||||
@@ -0,0 +1,982 @@
|
|||||||
|
# Video Player Controller v2
|
||||||
|
|
||||||
|
Video Player Controller downloads media into ten stable slots, publishes the
|
||||||
|
existing Home Assistant and Companion state topics, and coordinates playback
|
||||||
|
between the authoritative Video Player Node and the Audio Router.
|
||||||
|
|
||||||
|
Controller v2 is deliberately conservative: publishing a command is not proof
|
||||||
|
that playback changed. Only fresh Player Node status or telemetry can establish
|
||||||
|
playing, paused, or stopped state. A command accepted by the MQTT broker remains
|
||||||
|
`pending` until that evidence arrives.
|
||||||
|
|
||||||
|
The Controller does not run or supervise `mpv`. The Player Node owns the MPV
|
||||||
|
lifecycle and sends audio only to `pipewire/ytplayer_bus`; the Controller owns
|
||||||
|
the request/confirmation/restoration workflow for Audio Router mode.
|
||||||
|
|
||||||
|
## Architecture and safety model
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser / HA / Companion
|
||||||
|
|
|
||||||
|
| download requests and Controller commands
|
||||||
|
v
|
||||||
|
Flask -> bounded event queue -> single Controller coordinator
|
||||||
|
| | |
|
||||||
|
| | +-> SQLite state/audit
|
||||||
|
| +------------> bounded yt-dlp workers
|
||||||
|
+---------------------> MQTT
|
||||||
|
|
|
||||||
|
+-----------------------------+------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
Video Player Node Audio Router
|
||||||
|
(authoritative state) (physical audio mode)
|
||||||
|
|
|
||||||
|
+-> mpv -> pipewire/ytplayer_bus
|
||||||
|
```
|
||||||
|
|
||||||
|
MQTT callbacks, web requests, timers, and worker notifications converge on a
|
||||||
|
single coordinator thread for orchestration and publication. Download workers
|
||||||
|
perform their external work independently and commit only generation-fenced
|
||||||
|
SQLite transitions. SQLite transactions are the final persistence boundary.
|
||||||
|
This provides the following invariants:
|
||||||
|
|
||||||
|
- Slots `1` through `10` are permanent identities. A hole stays a hole; deleting
|
||||||
|
slot 3 never renames slot 4 or its directory.
|
||||||
|
- A download slot is reserved in an immediate SQLite transaction before a
|
||||||
|
worker is submitted. A partial unique index prevents two live jobs in one
|
||||||
|
slot.
|
||||||
|
- Every reuse increments the slot generation. A late worker or deletion token
|
||||||
|
cannot commit against a newer occupant.
|
||||||
|
- Active, pending, uncertain, or operator-review playback guards block slot
|
||||||
|
replacement and deletion.
|
||||||
|
- A reserved or running download blocks deletion and expiry.
|
||||||
|
- Filesystem deletion is limited to the canonical `slotN` directory. The
|
||||||
|
database first atomically fences the generation as `deleting`; filesystem
|
||||||
|
work then runs asynchronously. A directory is atomically moved under
|
||||||
|
`.controller-trash` before recursive removal, and interrupted deletion
|
||||||
|
recovery is deferred until Player safety is established.
|
||||||
|
- MQTT can be unavailable at startup. The web/coordinator process remains up,
|
||||||
|
reports degraded health, and retries with bounded exponential backoff.
|
||||||
|
|
||||||
|
The canonical managed media layout is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/Media/YTPlayer/
|
||||||
|
slot1/playlist.m3u
|
||||||
|
slot2/playlist.m3u
|
||||||
|
...
|
||||||
|
slot10/playlist.m3u
|
||||||
|
```
|
||||||
|
|
||||||
|
The base path is configurable, but both Controller and Player Node must see the
|
||||||
|
same absolute paths.
|
||||||
|
|
||||||
|
## Player-authoritative operation model
|
||||||
|
|
||||||
|
Controller commands are serialized. A second transition is rejected while a
|
||||||
|
play, stop, pause, or resume operation is waiting for evidence.
|
||||||
|
|
||||||
|
For a play request, the Controller:
|
||||||
|
|
||||||
|
1. Validates the stable slot or explicit local path and protects a managed
|
||||||
|
slot before any external command is sent.
|
||||||
|
2. Acquires the Audio Router mode using the bounded policy described below.
|
||||||
|
3. Publishes the Player command at QoS 1 with `retain=false` and waits up to
|
||||||
|
`mqtt.publish_ack_timeout_seconds` for the broker PUBACK.
|
||||||
|
4. Reports the request as pending. It does **not** publish `playing` merely
|
||||||
|
because the command was sent.
|
||||||
|
5. Confirms, rejects, or marks the operation uncertain from matching Player
|
||||||
|
status/telemetry. The normal Player-evidence deadline is 10 seconds.
|
||||||
|
|
||||||
|
A PUBACK proves only broker receipt, not Player execution. If the PUBACK is
|
||||||
|
uncertain, the Controller replaces its MQTT transport so an unacknowledged QoS
|
||||||
|
1 command cannot be replayed later, and it preserves uncertain playback state
|
||||||
|
for reconciliation.
|
||||||
|
|
||||||
|
Player evidence is interpreted using the current Player Node contract:
|
||||||
|
|
||||||
|
- `playing` status or telemetry with a path establishes active playback.
|
||||||
|
- Telemetry's boolean `paused` field is authoritative for pause state. Player
|
||||||
|
`paused` and `toggled` status only show that control was issued; Controller
|
||||||
|
waits for telemetry before confirming the resulting pause value. If pause,
|
||||||
|
resume, or toggle confirmation times out, both phase and pause value project
|
||||||
|
as `unknown` until fresh evidence arrives.
|
||||||
|
- `busy` means the new play did not replace the Player's existing process. When
|
||||||
|
a slot/path is present, or an earlier identity is still known, that existing
|
||||||
|
playback remains authoritative. A real pathless `busy` with no prior identity
|
||||||
|
becomes unknown; Controller keeps the Router lease and global cleanup fence
|
||||||
|
until identity/terminal evidence is reasserted or an administrator reconciles
|
||||||
|
the uncertainty.
|
||||||
|
- Terminal `idle` requires one of `stop`, `finished`, `mpv_error`, `sigterm`,
|
||||||
|
`sigint`, or `shutdown`, or cleared telemetry. An ambiguous idle does not
|
||||||
|
erase active ownership.
|
||||||
|
- A generic Player `error` is not proof that MPV stopped. Known managed or
|
||||||
|
external playback becomes reviewable uncertainty; an identity-less error
|
||||||
|
keeps the global cleanup fence closed. Only error reasons `startup_error` and
|
||||||
|
`worker_start_error` prove that playback never became active and reduce to
|
||||||
|
idle even if cleared telemetry was lost.
|
||||||
|
- On reconnect, `idle` with `message:"player online"` and a path means the
|
||||||
|
Player is still active. The same announcement without a path means idle.
|
||||||
|
- The retained flat `youtube/player/state` topic is never playback or cleanup
|
||||||
|
evidence. Its value is historical across outages, and Player reconnect
|
||||||
|
deliberately emits flat `idle` even while MPV is active. Controller keeps the
|
||||||
|
subscription for Companion compatibility but does not use it to unlock
|
||||||
|
downloads, expiry, deletion recovery, or playback guards.
|
||||||
|
|
||||||
|
Use `youtube/controller/cmd` for operational control. Publishing directly to
|
||||||
|
`youtube/player/cmd` bypasses Controller Audio Router acquisition and slot
|
||||||
|
guard creation. Controller will eventually reduce resulting Player evidence,
|
||||||
|
but there is an unavoidable cross-service race before that evidence arrives.
|
||||||
|
|
||||||
|
## MQTT contracts
|
||||||
|
|
||||||
|
Defaults remain compatible with the existing Player Node, Home Assistant, and
|
||||||
|
Companion integrations. Topic names can be configured except where another
|
||||||
|
component treats one as fixed; coordinate any change across all consumers.
|
||||||
|
|
||||||
|
### Topic matrix
|
||||||
|
|
||||||
|
| Direction | Default topic | Payload | QoS used by Controller | Retained | Contract status |
|
||||||
|
|---|---|---|---:|---:|---|
|
||||||
|
| Controller -> Player | `youtube/player/cmd` | JSON command | publish 1 | no | Preserved; required |
|
||||||
|
| Player -> Controller | `youtube/player/status` | JSON status | subscribe 1 | no | Player-authoritative |
|
||||||
|
| Player -> Controller | `youtube/player/state` | plain flat state | subscribe 0 | yes, by Player | Preserved Companion contract only; never cleanup evidence |
|
||||||
|
| Player -> Controller | `youtube/player/telemetry` | JSON telemetry | subscribe 0 | no | Player-authoritative |
|
||||||
|
| HA/Companion -> Controller | `youtube/controller/cmd` | JSON command | subscribe 1 | must be no | Preserved; retained commands are rejected |
|
||||||
|
| Controller -> consumers | `youtube/ingest/progress` | JSON progress | publish 0 | no | Preserved |
|
||||||
|
| Controller -> consumers | `youtube/media/playback/<slot>/title` | plain string | publish 0 | yes | Preserved |
|
||||||
|
| Controller -> consumers | `youtube/media/playback/<slot>/state` | plain string | publish 0 | yes | Preserved |
|
||||||
|
| Controller -> consumers | `youtube/media/playback/<slot>/count` | decimal string | publish 0 | yes | Preserved |
|
||||||
|
| Controller -> consumers | `youtube/media/playback/<slot>/items/<position>` | plain string | publish 0 | yes | Preserved; empty payload clears stale items |
|
||||||
|
| Controller/operator -> Router | `audio/player/mode/set` | plain mode string | publish and subscribe 1 | Controller publishes no | Router contract |
|
||||||
|
| Router -> Controller | `audio/player/mode/state` | plain mode string | subscribe 1 | Router-owned | Router confirmation |
|
||||||
|
| Controller -> consumers | `youtube/controller/v2/events` | JSON event | publish 1 | no | Optional, additive v2 contract |
|
||||||
|
| Controller -> Home Assistant | `homeassistant/sensor/yt_slot_<slot>_<field>/config` | discovery JSON | publish 0 | yes | Preserved IDs |
|
||||||
|
|
||||||
|
QoS in the incoming rows is the Controller's requested subscription QoS; the
|
||||||
|
publisher and broker determine the delivered QoS. Player commands and Router
|
||||||
|
mode commands are always emitted with QoS 1 and `retain=false`.
|
||||||
|
|
||||||
|
The Controller uses Paho manual acknowledgement for incoming QoS 1 messages.
|
||||||
|
It acknowledges after successful coordinator handling and deduplicates repeated
|
||||||
|
packets by MQTT connection generation, topic, and packet ID. Retained Controller
|
||||||
|
commands are rejected so a reconnect cannot replay an old control operation.
|
||||||
|
|
||||||
|
Detailed Player status and telemetry are valid evidence only when
|
||||||
|
`retain=false`. If either arrives retained, Controller treats it as historical,
|
||||||
|
does not reduce it, does not unlock cleanup, and does not clear guards. It logs
|
||||||
|
and audits `retained_player_evidence_rejected`, publishing that warning when the
|
||||||
|
optional v2 event topic is enabled; a retained QoS 1 message is deliberately
|
||||||
|
acknowledged after rejection so the same invalid evidence is not redelivered
|
||||||
|
indefinitely.
|
||||||
|
|
||||||
|
### Controller command payloads
|
||||||
|
|
||||||
|
Publish all examples at QoS 1 without retain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosquitto_pub -h 10.40.0.2 -t youtube/controller/cmd -q 1 \
|
||||||
|
-m '{"action":"play","slot":1,"label":"Opening"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported JSON objects are:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"play","slot":1,"label":"Opening"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"play","path":"/mnt/Media/YTPlayer/special/welcome.mp4","label":"Welcome"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"play","playlist":"/mnt/Media/YTPlayer/special/playlist.m3u","title":"Special"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"stop"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"pause"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"resume"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"toggle"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"delete_slot","slot":4,"url":"optional stale-command guard"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`play` requires a ready managed slot or an existing local `path`/`playlist`.
|
||||||
|
An explicit path inside a managed slot derives that stable slot identity; an
|
||||||
|
explicit conflicting slot is rejected. `toggle` is translated to `pause` or
|
||||||
|
`resume` from the latest telemetry and is rejected when pause state is unknown.
|
||||||
|
|
||||||
|
When the global cleanup gate lacks fresh Player evidence, manual deletion also
|
||||||
|
requires the exact boolean override:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"delete_slot","slot":4,"confirm_player_unknown":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
That override only bypasses the global evidence gate. It does not bypass an
|
||||||
|
active download or a per-slot active/uncertain playback guard. It is separate
|
||||||
|
from administrative reconciliation, authorizes only that manual delete, and
|
||||||
|
does not open automatic expiry/recovery or consume a cleanup token.
|
||||||
|
|
||||||
|
An accepted manual delete is atomically fenced in SQLite and returns
|
||||||
|
`{"status":"deleting","slot":4}`, not `deleted`. Filesystem removal happens
|
||||||
|
on a worker afterward. MQTT commands have no synchronous response topic, so an
|
||||||
|
MQTT consumer observes the retained slot state (and optional v2 warnings) rather
|
||||||
|
than depending on that in-process return value.
|
||||||
|
|
||||||
|
### Preserved slot and Home Assistant state
|
||||||
|
|
||||||
|
For every stable slot, Controller publishes retained `title`, `state`, and
|
||||||
|
`count` values plus retained item titles. Projected state values include
|
||||||
|
`empty`, `queued`, `downloading`, `ready`, `error`, `deleting`, `playing`,
|
||||||
|
`paused`, `unknown`, and `operator_review`. A pending play remains `ready`
|
||||||
|
until Player evidence says otherwise.
|
||||||
|
|
||||||
|
The thirty discovery entities keep the established identifiers:
|
||||||
|
|
||||||
|
- Discovery topic: `homeassistant/sensor/yt_slot_<N>_<title|state|count>/config`
|
||||||
|
- Unique ID: `yt_slot_<N>_<title|state|count>`
|
||||||
|
- Device identifier: `ytplayer-controller`
|
||||||
|
- Device name/manufacturer/model: `YouTube Controller` / `River of Glory` /
|
||||||
|
`YT Controller`
|
||||||
|
|
||||||
|
Changing `homeassistant.discovery_prefix` changes only the discovery prefix.
|
||||||
|
Set `homeassistant.enabled: false` to suppress discovery; legacy slot state
|
||||||
|
publication continues.
|
||||||
|
|
||||||
|
### Ingest progress
|
||||||
|
|
||||||
|
`youtube/ingest/progress` is a non-retained JSON stream. Payloads always contain
|
||||||
|
`slot` and `state`, with applicable fields such as `title`, `count`, `message`,
|
||||||
|
`url`, `submitter`, and `note`. A `queued` event also contains `reused_slot`.
|
||||||
|
Common states are `queued`, `downloading`, `ready`, and `error`.
|
||||||
|
|
||||||
|
### Optional v2 events
|
||||||
|
|
||||||
|
`topics.controller_result` defaults to `youtube/controller/v2/events`. Set it
|
||||||
|
to YAML `null` to disable publication. This topic is deliberately additive:
|
||||||
|
legacy MQTT, Home Assistant, and Companion consumers never need it.
|
||||||
|
|
||||||
|
Events are QoS 1, non-retained JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"event_id": "6ed8f0e49a8b4d598e5285ca14ec89ef",
|
||||||
|
"type": "audio_router_warning",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "Audio Router did not confirm 'video' within 3 seconds",
|
||||||
|
"ts": 1786060800.0,
|
||||||
|
"slot": 1,
|
||||||
|
"operation_id": "4e32c8d628454426bff69c879e709f33",
|
||||||
|
"continued": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot`, `operation_id`, and `continued` appear only when relevant. Each event
|
||||||
|
is also written to `audit_events` and logged, so the non-retained stream is not
|
||||||
|
the durable record. Event publication itself depends on MQTT availability.
|
||||||
|
|
||||||
|
## Audio Router orchestration
|
||||||
|
|
||||||
|
The normal playback target is the plain mode `video`:
|
||||||
|
|
||||||
|
- Command: `audio/player/mode/set`
|
||||||
|
- Confirmed state: `audio/player/mode/state`
|
||||||
|
|
||||||
|
Before play, Controller snapshots the latest confirmed Router state and creates
|
||||||
|
one persisted lease for the playback session. Router acquisition and restoration
|
||||||
|
both use the finite `router.confirmation_timeout_seconds`; neither can hang a
|
||||||
|
play operation indefinitely.
|
||||||
|
|
||||||
|
### Unknown-state and failure policy
|
||||||
|
|
||||||
|
The four Router settings are independent:
|
||||||
|
|
||||||
|
| Setting | Default | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `router.confirmation_timeout_seconds` | `3` | Maximum wait for acquisition or restoration confirmation |
|
||||||
|
| `router.when_state_unknown` | `attempt` | `attempt`, `continue`, or `reject` when no current state exists |
|
||||||
|
| `router.continue_on_confirmation_failure` | `true` | Continue playback after publish failure, disconnect, operator conflict, or confirmation timeout |
|
||||||
|
| `router.fallback_restore_mode` | `null` | Optional restoration target when the prior mode was unknown |
|
||||||
|
|
||||||
|
`when_state_unknown` has these meanings:
|
||||||
|
|
||||||
|
- `attempt`: request `video`, wait for the bounded confirmation, then apply
|
||||||
|
`continue_on_confirmation_failure` if it cannot be confirmed.
|
||||||
|
- `continue`: request `video` and start playback immediately without claiming
|
||||||
|
restoration ownership. This still records and publishes a warning.
|
||||||
|
- `reject`: reject this play request without sending a Player command. The
|
||||||
|
caller may retry after a current Router state becomes available.
|
||||||
|
|
||||||
|
The safe service default is `attempt` plus
|
||||||
|
`continue_on_confirmation_failure: true`: it tries to set and confirm `video`
|
||||||
|
for three seconds, but unavailable Router state cannot hang or permanently
|
||||||
|
disable playback. The warning is persisted, logged, and published on the
|
||||||
|
optional v2 event topic when MQTT permits.
|
||||||
|
|
||||||
|
`fallback_restore_mode` is used only when the previous mode was unknown and
|
||||||
|
acquisition subsequently becomes confirmed. A play that continues immediately
|
||||||
|
or after failed confirmation has no safe restoration ownership, so it does not
|
||||||
|
apply the fallback later.
|
||||||
|
|
||||||
|
### Safe restoration and manual override
|
||||||
|
|
||||||
|
At authoritative playback termination, Controller restores the prior mode only
|
||||||
|
when all of these remain true:
|
||||||
|
|
||||||
|
- Controller actually changed the mode and received confirmation of `video`.
|
||||||
|
- The same lease is still held.
|
||||||
|
- The latest Router state is still the Controller-owned target.
|
||||||
|
- MQTT continuity was not lost.
|
||||||
|
- No live, non-self mode command and no conflicting state change indicated an
|
||||||
|
operator override.
|
||||||
|
|
||||||
|
Controller subscribes to both Router mode set and state topics. Its own
|
||||||
|
mode-set publication is correlated by exact payload and stays recognized as
|
||||||
|
a self-echo both while the publish is in flight and, because a broker may
|
||||||
|
legally deliver a subscription echo after the PUBACK that acknowledged the
|
||||||
|
same publish, for a short `mqtt.router_echo_grace_seconds` window afterward
|
||||||
|
(default 2 seconds). Every other live mode-set publication is treated
|
||||||
|
conservatively as operator intent, even if it repeats `video`. A retained set
|
||||||
|
message is ignored as stale intent. Controller does not publish Router state;
|
||||||
|
it accepts a received live or retained state message as the Router's current
|
||||||
|
mode. Once ownership is lost, Controller does not take it back merely because
|
||||||
|
state later returns to `video`.
|
||||||
|
|
||||||
|
Restoration is requested once and confirmed within the same bounded timeout.
|
||||||
|
Failure produces a warning and no automatic retry. The Router's plain string
|
||||||
|
protocol has no publisher identity or compare-and-set token, so an exactly
|
||||||
|
simultaneous same-value command cannot be distinguished perfectly; conservative
|
||||||
|
ownership loss avoids overwriting a known manual choice.
|
||||||
|
|
||||||
|
## Downloads, retention, and stable slots
|
||||||
|
|
||||||
|
The web form reserves the first empty stable slot, or reuses the slot already
|
||||||
|
associated with the same URL when it is safe. Reservation and the `reserved`
|
||||||
|
download job commit before a worker starts. `downloads.max_workers` bounds
|
||||||
|
concurrent yt-dlp work; it does not change the ten-slot capacity.
|
||||||
|
|
||||||
|
Each successful download builds `slotN/playlist.m3u` atomically and records item
|
||||||
|
metadata in the same slot generation. Downloads run yt-dlp with an explicit Node
|
||||||
|
JavaScript runtime and ffmpeg support. On restart, database jobs left in
|
||||||
|
`reserved` or `running` become `interrupted`, and their slots become `error`
|
||||||
|
instead of pretending success. Whenever a failed or restart-interrupted job
|
||||||
|
would otherwise leave an error slot without expiry, Controller assigns
|
||||||
|
`now + storage.retention_seconds`. This fallback does not replace an existing
|
||||||
|
expiry and prevents repeated failed downloads from permanently exhausting all
|
||||||
|
ten slots.
|
||||||
|
|
||||||
|
Ready and error media can expire after `storage.retention_seconds`, checked at
|
||||||
|
`storage.expiry_interval_seconds`. Cleanup skips any slot with a live job or
|
||||||
|
playback guard. Cleanup and interrupted-deletion recovery are globally deferred
|
||||||
|
until safe detailed Player status/telemetry or an explicit audited
|
||||||
|
reconciliation makes cleanup safe. The retained flat Player state never unlocks
|
||||||
|
this fence.
|
||||||
|
|
||||||
|
Manual and expiry deletion use the same asynchronous sequence:
|
||||||
|
|
||||||
|
1. A transaction rechecks media state, URL guard, live jobs, and playback
|
||||||
|
guards, increments the generation, and commits state `deleting`.
|
||||||
|
2. A bounded worker rechecks playback guards before touching the filesystem,
|
||||||
|
atomically moves the canonical directory to generation-specific trash, and
|
||||||
|
checks again before recursive removal. If a late guard appears after the
|
||||||
|
move, the worker restores the directory and leaves deletion pending.
|
||||||
|
3. Only successful filesystem completion changes the database row to `empty`.
|
||||||
|
A failure remains durably `deleting`, emits `slot_deletion_pending`, and is
|
||||||
|
retried without blocking the coordinator. Error retries are accelerated to
|
||||||
|
at most 30 seconds; otherwise pending deletions are reconsidered during each
|
||||||
|
cleanup/expiry cycle and whenever safe Player evidence or reconciliation
|
||||||
|
releases the fence.
|
||||||
|
|
||||||
|
Only one worker handles a given slot generation at a time. A late guard or a
|
||||||
|
stale generation therefore defers/fences completion instead of deleting a newer
|
||||||
|
occupant. A `deleting` state can be visible for more than one interval during a
|
||||||
|
filesystem failure or active playback review; that is deliberate, recoverable
|
||||||
|
state rather than a completed delete.
|
||||||
|
|
||||||
|
Expiry never compacts slots. An expired slot becomes empty; every other slot
|
||||||
|
keeps its number and canonical directory.
|
||||||
|
|
||||||
|
## Restart uncertainty and administrative reconciliation
|
||||||
|
|
||||||
|
On startup, a last-known managed active slot or pending play is converted to a
|
||||||
|
`restart_uncertain` guard. An active path outside managed slots receives its
|
||||||
|
own persisted reconciliation ID. These records remain deletion-protected until
|
||||||
|
fresh Player evidence confirms active playback or a terminal state clears it.
|
||||||
|
|
||||||
|
Separately, every Controller start and every MQTT connect/disconnect closes the
|
||||||
|
global cleanup fence until safe detailed Player evidence arrives. These are two
|
||||||
|
different reconciliation scopes: durable playback ownership reviews protect a
|
||||||
|
known slot/path, while a connection-scoped cleanup token handles the narrower
|
||||||
|
case where no active identity or guard is known but Player evidence is absent.
|
||||||
|
|
||||||
|
### Durable playback ownership reviews
|
||||||
|
|
||||||
|
`storage.uncertain_review_after_seconds` defaults to 900 seconds. Reaching that
|
||||||
|
deadline changes `awaiting_evidence` to `operator_review`; it **never** assumes
|
||||||
|
the Player stopped and never removes protection. Set the value to `0` for no
|
||||||
|
automatic review deadline. Review events are re-announced after each broker
|
||||||
|
connection.
|
||||||
|
|
||||||
|
Find durable review tokens in `GET /healthz` under
|
||||||
|
`reconciliation_reviews`. When the optional event topic is enabled, the same
|
||||||
|
token appears as `operation_id` on `playback_reconciliation_required`.
|
||||||
|
|
||||||
|
To clear a managed-slot review after independently verifying that playback is
|
||||||
|
not active:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "reconcile_playback",
|
||||||
|
"decision": "clear",
|
||||||
|
"confirm": true,
|
||||||
|
"slot": 1,
|
||||||
|
"review_id": "copy-the-current-review-id",
|
||||||
|
"actor": "operator-name",
|
||||||
|
"reason": "Player host was replaced and old playback cannot still exist"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For uncertain external playback, omit `slot`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "reconcile_playback",
|
||||||
|
"decision": "clear",
|
||||||
|
"confirm": true,
|
||||||
|
"review_id": "copy-the-current-review-id",
|
||||||
|
"actor": "operator-name",
|
||||||
|
"reason": "Retired Player host verified offline"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact current review ID, `decision:"clear"`, and boolean `confirm:true`
|
||||||
|
are required. A stale ID is rejected, as is reconciliation while a Player
|
||||||
|
operation is pending. Clearing a review does not send stop to the Player; it
|
||||||
|
states that the operator has established safety and can permit later cleanup.
|
||||||
|
An incorrect clear can therefore allow deletion of media still in use.
|
||||||
|
|
||||||
|
### Connection-scoped cleanup reconciliation
|
||||||
|
|
||||||
|
Detailed evidence unlocks the global cleanup fence only when it proves either
|
||||||
|
terminal idle or an online active identity. Examples are cleared telemetry,
|
||||||
|
terminal/reconnect-idle status without a path, matching active telemetry/status,
|
||||||
|
or a busy/control/reconnect status backed by a known slot/path. Retained flat
|
||||||
|
state, pathless `busy`, identity-less generic `error`, malformed messages, and
|
||||||
|
mere MQTT connectivity do not qualify.
|
||||||
|
|
||||||
|
When cleanup is fenced but there is no known active identity and no live slot
|
||||||
|
guard, `/healthz` exposes the current token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cleanup_reconciliation": {
|
||||||
|
"review_id": "copy-the-current-connection-token",
|
||||||
|
"kind": "player_evidence",
|
||||||
|
"state": "awaiting_evidence",
|
||||||
|
"clearable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is `null` once detailed evidence/override has opened cleanup, and is
|
||||||
|
also withheld when a known identity or guard requires the durable playback
|
||||||
|
review workflow instead. `clearable:false` means a Router-gated play or Player
|
||||||
|
operation is still pending.
|
||||||
|
|
||||||
|
After independently verifying that no Player can be using managed media,
|
||||||
|
publish this exact administrative command:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "reconcile_playback",
|
||||||
|
"scope": "cleanup",
|
||||||
|
"decision": "clear",
|
||||||
|
"confirm": true,
|
||||||
|
"review_id": "copy-the-current-connection-token",
|
||||||
|
"actor": "operator-name",
|
||||||
|
"reason": "Player appliance was independently verified offline"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The accepted in-process result is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "cleared",
|
||||||
|
"review_id": "copy-the-current-connection-token",
|
||||||
|
"scope": "cleanup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This token is intentionally not durable: it rotates on Controller startup and
|
||||||
|
every MQTT connection or continuity loss. Fetch `/healthz` again immediately
|
||||||
|
before publishing; an older token is rejected. The clear is connection-scoped,
|
||||||
|
recorded as `player_evidence_admin_override` in the audit database, and announced
|
||||||
|
as the optional `playback_reconciled` event. Later MQTT continuity loss closes
|
||||||
|
the fence and creates a new token.
|
||||||
|
|
||||||
|
For a pathless `busy`, accepting this cleanup reconciliation also abandons the
|
||||||
|
held Router lease instead of guessing that playback ended and restoring an old
|
||||||
|
mode. The operator then owns any subsequent Router change. Fresh reasserted
|
||||||
|
playback followed by terminal evidence uses the normal safe restoration path.
|
||||||
|
|
||||||
|
Cleanup scope accepts no `slot` and cannot clear or bypass a known active path,
|
||||||
|
slot guard, active download, or pending Player/Router transition. Use the
|
||||||
|
durable slot/external review payloads above for known playback uncertainty. This
|
||||||
|
override opens cleanup scheduling; each slot deletion still performs its own
|
||||||
|
transactional job/guard checks and late-guard checks.
|
||||||
|
|
||||||
|
`actor` and `reason` are durable audit fields, but `actor` is a caller-supplied
|
||||||
|
label, not authentication or authorization. Enforce administrative access with
|
||||||
|
broker credentials and topic ACLs.
|
||||||
|
|
||||||
|
## SQLite persistence
|
||||||
|
|
||||||
|
Controller v2 uses one embedded SQLite database at
|
||||||
|
`<storage.data_base>/<storage.database_name>`, by default
|
||||||
|
`/controller-data/controller.sqlite3`. It introduces no external database
|
||||||
|
service.
|
||||||
|
|
||||||
|
Schema version 2 contains:
|
||||||
|
|
||||||
|
| Table | Purpose and important constraints |
|
||||||
|
|---|---|
|
||||||
|
| `schema_migrations` | Applied internal schema versions; v1 databases upgrade transactionally to v2 |
|
||||||
|
| `data_migrations` | One-time import provenance, source hash, backup path, and warnings |
|
||||||
|
| `slots` | Exactly one row for each stable slot 1-10, generation, media state, identity, timestamps, expiry, and error |
|
||||||
|
| `slot_items` | Ordered item title/path metadata keyed by slot and position |
|
||||||
|
| `download_jobs` | Reservation/worker lifecycle keyed by job ID and slot generation; one live job per slot |
|
||||||
|
| `slot_guards` | Active, pending, restart-uncertain, and legacy-uncertain playback protection; one live guard per slot |
|
||||||
|
| `player_observation` | Singleton last status/telemetry reduction, generation, active identity, pause, and reconciliation state/token |
|
||||||
|
| `audio_router_observation` | Singleton last mode, freshness, timestamp, and warning |
|
||||||
|
| `audio_leases` | Acquisition, ownership, restoration, deadline, and failure history for Router sessions |
|
||||||
|
| `audit_events` | Durable migration, safety, reconciliation, and warning history |
|
||||||
|
|
||||||
|
Runtime connections enable foreign keys, a 5-second busy timeout, WAL journaling,
|
||||||
|
and `synchronous=FULL`. Writes use `BEGIN IMMEDIATE` transactions behind a
|
||||||
|
re-entrant process lock. Opening and migrating the store also takes the
|
||||||
|
cross-process `/controller-data/.controller-store.lock` file lock. Production
|
||||||
|
must run one application worker; these database protections are not permission
|
||||||
|
to start duplicate coordinators or MQTT clients.
|
||||||
|
|
||||||
|
For a consistent manual backup, stop Controller cleanly before copying the
|
||||||
|
entire data directory. If an unclean live copy is unavoidable, preserve
|
||||||
|
`controller.sqlite3`, `controller.sqlite3-wal`, and `controller.sqlite3-shm` as
|
||||||
|
a set.
|
||||||
|
|
||||||
|
## One-time `playlists.yaml` migration
|
||||||
|
|
||||||
|
Migration runs automatically only when both conditions are true:
|
||||||
|
|
||||||
|
1. The final SQLite database does not exist.
|
||||||
|
2. `<storage.data_base>/<storage.legacy_yaml_name>` exists, normally
|
||||||
|
`/controller-data/playlists.yaml`.
|
||||||
|
|
||||||
|
The migration is non-destructive and idempotent:
|
||||||
|
|
||||||
|
1. It takes the process lock and rechecks database existence.
|
||||||
|
2. It reads at most 8 MiB and rejects a source whose inode, size, or mtime
|
||||||
|
changes during the read.
|
||||||
|
3. It computes the full SHA-256 and creates a content-addressed, fsynced backup
|
||||||
|
named `playlists.yaml.pre-v2.<full-sha256>.bak`. An existing backup is reused
|
||||||
|
only if its hash matches.
|
||||||
|
4. It validates UTF-8, unique YAML mapping keys, slot keys in 1-10, and agreement
|
||||||
|
between each key and embedded `slot` identity.
|
||||||
|
5. It creates a separate staging SQLite file, initializes all ten slots, imports
|
||||||
|
the YAML in one transaction, records migration provenance, and checks schema,
|
||||||
|
foreign keys, integrity, and the exact 1-10 slot set.
|
||||||
|
6. It fsyncs and atomically replaces the final database path only after all
|
||||||
|
validation succeeds.
|
||||||
|
|
||||||
|
The original YAML is never renamed, edited, or removed. The backup is created
|
||||||
|
before YAML parsing, so even malformed YAML can leave a verified recovery copy.
|
||||||
|
A parsing, import, or validation failure rolls back the transaction, removes the
|
||||||
|
staging database, and occurs before final database installation. The final step
|
||||||
|
is an atomic replace followed by a directory fsync; if that last fsync itself
|
||||||
|
fails, a validated final database may already be present and must be inspected
|
||||||
|
rather than overwritten or blindly reimported. Fix or restore the YAML and retry
|
||||||
|
only when the final database is absent, or return to the old deployment; do not
|
||||||
|
discard the original or hash-named backup.
|
||||||
|
|
||||||
|
Once `controller.sqlite3` exists, startup never imports YAML again and later
|
||||||
|
YAML edits are ignored. That database-presence rule plus the `data_migrations`
|
||||||
|
record makes the migration idempotent. An existing schema-v1 Controller SQLite
|
||||||
|
database is upgraded transactionally rather than reimporting YAML.
|
||||||
|
|
||||||
|
Legacy state mapping is explicit:
|
||||||
|
|
||||||
|
| Legacy state | v2 media state | Extra treatment |
|
||||||
|
|---|---|---|
|
||||||
|
| `ready`, `idle`, `finished` | `ready` | Preserves slot number, metadata, and expiry |
|
||||||
|
| `playing`, `paused`, `busy` | `ready` | Creates a `legacy_uncertain` operator-review guard |
|
||||||
|
| `queued`, `downloading` | `error` | Records an interrupted download job and warning |
|
||||||
|
| `error` | `error` | Preserves or supplies the error message |
|
||||||
|
| any unknown value | `error` | Records a warning rather than guessing |
|
||||||
|
|
||||||
|
Missing URLs and invalid counts/timestamps are recorded as errors or migration
|
||||||
|
warnings. Parseable legacy UTC timestamps are converted to Unix timestamps.
|
||||||
|
Any migrated error/interrupted slot with no usable legacy expiry receives the
|
||||||
|
same migration-time `now + storage.retention_seconds` fallback, recorded as a
|
||||||
|
migration warning, so it can later expire when cleanup is safe. An existing
|
||||||
|
legacy expiry is preserved.
|
||||||
|
Migration does not move, rename, compact, or delete media. Legacy YAML does not
|
||||||
|
contain complete per-item paths, so v2 does not invent them; existing playlist
|
||||||
|
files and retained item topics remain untouched until authoritative v2 metadata
|
||||||
|
is available.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`config.yaml` is loaded relative to the application, not the invoking shell's
|
||||||
|
working directory. The production image bind-mounts it read-only at
|
||||||
|
`/app/config.yaml`. Configuration is validated before service startup and has no
|
||||||
|
hot reload; restart the one-worker service after changes.
|
||||||
|
|
||||||
|
### Environment overrides
|
||||||
|
|
||||||
|
Only the following environment variables override YAML:
|
||||||
|
|
||||||
|
| Environment variable | YAML setting | Application default |
|
||||||
|
|---|---|---|
|
||||||
|
| `MQTT_HOST` | `mqtt.host` | `10.40.0.2` |
|
||||||
|
| `MQTT_PORT` | `mqtt.port` | `1883` |
|
||||||
|
| `MQTT_USERNAME` | `mqtt.username` | unset |
|
||||||
|
| `MQTT_PASSWORD` | `mqtt.password` | unset |
|
||||||
|
| `MQTT_CLIENT_ID` | `mqtt.client_id` | `yt-controller` |
|
||||||
|
| `YTPLAYER_MEDIA_BASE` | `storage.media_base` | `/mnt/Media/YTPlayer` |
|
||||||
|
| `YTPLAYER_DATA_BASE` | `storage.data_base` | `/controller-data` |
|
||||||
|
| `YTPLAYER_RETENTION_SECONDS` | `storage.retention_seconds` | `172800` |
|
||||||
|
| `YTPLAYER_COOKIE_PATH` | `downloads.cookie_path` | `/run/secrets/yt-dlp-cookies.txt` |
|
||||||
|
| `PORT` | `web.port` | `7531` |
|
||||||
|
|
||||||
|
MQTT username and password must either both be set or both be unset. Media,
|
||||||
|
data, and cookie paths must be absolute. `storage.slot_count` is validated as
|
||||||
|
exactly 10 and cannot be used to compact or resize the stable namespace.
|
||||||
|
|
||||||
|
Compose-only deployment variables are documented in `.env.example`:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `FLASK_SECRET_KEY` | Required long random Flask signing secret; no production fallback |
|
||||||
|
| `CONTROLLER_DATA_DIR` | Host directory mounted at `/controller-data` |
|
||||||
|
| `YTPLAYER_MEDIA_DIR` | Host media directory mounted at `/mnt/Media/YTPlayer` |
|
||||||
|
| `CONTROLLER_SECRETS_DIR` | Host secrets directory mounted read-only at `/run/secrets` |
|
||||||
|
| `CONTROLLER_WEB_PORT` | Host-side published HTTP port |
|
||||||
|
| `CONTROLLER_UID`, `CONTROLLER_GID` | Non-root image user/group matching writable host paths |
|
||||||
|
|
||||||
|
### YAML settings
|
||||||
|
|
||||||
|
The checked-in `config.yaml` is the canonical example. Important defaults are:
|
||||||
|
|
||||||
|
- MQTT: 30-second keepalive, 1-to-30-second reconnect backoff, 512-entry event
|
||||||
|
queue, and 2-second QoS 1 PUBACK timeout.
|
||||||
|
- Storage: 48-hour retention, 60-second expiry scan, and 15-minute uncertainty
|
||||||
|
review deadline.
|
||||||
|
- Downloads: two workers, 2-hour subprocess timeout, maximum height 1080, and
|
||||||
|
optional cookies.
|
||||||
|
- Router: required mode `video`, 3-second confirmation timeout, unknown-state
|
||||||
|
policy `attempt`, continue after confirmation failure, and no fallback restore
|
||||||
|
mode.
|
||||||
|
- Home Assistant: discovery enabled under `homeassistant`.
|
||||||
|
- Web: bind `0.0.0.0:7531`.
|
||||||
|
|
||||||
|
All remaining MQTT topic defaults are listed in the topic matrix. Numeric values
|
||||||
|
must be finite and within their validated ranges.
|
||||||
|
|
||||||
|
### Cookies
|
||||||
|
|
||||||
|
Place an optional Netscape-format cookie file at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<CONTROLLER_SECRETS_DIR>/yt-dlp-cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose exposes it as `/run/secrets/yt-dlp-cookies.txt`. For each yt-dlp command,
|
||||||
|
Controller makes a mode-0600 temporary copy under `/tmp`, passes that copy to
|
||||||
|
yt-dlp, and removes it afterward. The source is never copied into the image.
|
||||||
|
|
||||||
|
With `downloads.cookie_required: false` (the default), a missing cookie file is
|
||||||
|
logged and download continues without cookies. Set it to `true` when silent
|
||||||
|
cookie omission is unacceptable. Keep the host file out of Git, restrict its
|
||||||
|
permissions, and rotate it as a credential.
|
||||||
|
|
||||||
|
## HTTP and health
|
||||||
|
|
||||||
|
The HTTP surface is intentionally small:
|
||||||
|
|
||||||
|
- `GET /` renders the playlist submission form.
|
||||||
|
- `POST /submit` accepts an HTTP(S) URL plus optional submitter and note.
|
||||||
|
- `GET /healthz` returns service and reconciliation state as JSON.
|
||||||
|
|
||||||
|
Health includes `status`, `started`, `coordinator_alive`, `mqtt_connected`,
|
||||||
|
`router_state`, `player`, durable `reconciliation_reviews`, connection-scoped
|
||||||
|
`cleanup_reconciliation`, and `degraded_reasons`. Typical degraded reasons are
|
||||||
|
`mqtt_disconnected`, `awaiting_player_evidence`, and
|
||||||
|
`router_state_unavailable`.
|
||||||
|
|
||||||
|
The endpoint returns HTTP 200 whenever the service is started and its coordinator
|
||||||
|
thread is alive, including degraded MQTT or Router conditions. It returns 503
|
||||||
|
when the service is stopped, the coordinator is dead, or health evaluation
|
||||||
|
fails. This means a broker outage does not make Docker restart an otherwise
|
||||||
|
healthy Controller. A configuration, storage, or migration failure prevents
|
||||||
|
startup and therefore cannot produce a healthy endpoint.
|
||||||
|
|
||||||
|
## Docker deployment
|
||||||
|
|
||||||
|
The production image and Compose file intentionally differ from Controller v1:
|
||||||
|
|
||||||
|
- Gunicorn runs one `gthread` worker with four HTTP threads and `preload=false`.
|
||||||
|
One process must own the singleton coordinator, MQTT client, executor, and
|
||||||
|
SQLite connection. Do not increase `workers` or enable development hot reload.
|
||||||
|
- The container runs as configurable non-root UID/GID with all Linux
|
||||||
|
capabilities dropped and `no-new-privileges` enabled.
|
||||||
|
- The root filesystem is read-only. Only the data/media bind mounts and bounded
|
||||||
|
`/tmp` tmpfs are writable.
|
||||||
|
- The persistent Controller data mount is `/controller-data`; media is mounted
|
||||||
|
at the same `/mnt/Media/YTPlayer` path used by Player Node.
|
||||||
|
- Cookies are supplied through the read-only `/run/secrets` bind mount instead
|
||||||
|
of the old `/cookies_ro.txt` path.
|
||||||
|
- The image includes ffmpeg and a Node 22 runtime required by the pinned yt-dlp
|
||||||
|
YouTube JavaScript support.
|
||||||
|
- The build currently uses `node:22.23.1-bookworm-slim` as a JavaScript-runtime
|
||||||
|
stage and `python:3.12-slim-bookworm` as the final base. Runtime Python
|
||||||
|
requirements are exact pins, including Flask 3.1.3, Gunicorn 26.0.0,
|
||||||
|
paho-mqtt 2.1.0, PyYAML 6.0.3, and yt-dlp 2026.7.4.
|
||||||
|
- The HTTP health check, 45-second stop grace, bounded JSON logs, init process,
|
||||||
|
and `unless-stopped` restart policy are explicit.
|
||||||
|
- `tabernet` remains an external Docker network and must already exist.
|
||||||
|
|
||||||
|
Create a deployment environment file and secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the generated value in `FLASK_SECRET_KEY`, set every host path, and ensure
|
||||||
|
`CONTROLLER_UID`/`CONTROLLER_GID` can write the data and media directories.
|
||||||
|
Then validate and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network inspect tabernet
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose build --pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=200 controller
|
||||||
|
curl -fsS http://127.0.0.1:7531/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade from Controller v1
|
||||||
|
|
||||||
|
Use a maintenance window. Replace the example paths below with the paths from
|
||||||
|
the existing deployment; the historical Compose deployment commonly used
|
||||||
|
`/home/media/Scripts/Video-Player` as `/controller-data`.
|
||||||
|
|
||||||
|
1. Record the exact old Git commit or image ID, Compose file, configuration,
|
||||||
|
environment, UID/GID, and MQTT settings. Keep the old image locally.
|
||||||
|
2. Stop new submissions and let downloads finish. Prefer to stop playback and
|
||||||
|
verify authoritative Player terminal/cleared telemetry before the cutover.
|
||||||
|
If Player must remain active, leave it running; v2 will start conservatively
|
||||||
|
and protect last-known activity until fresh evidence arrives.
|
||||||
|
3. Stop only the old Controller. Do not modify, upgrade, or replace Player Node
|
||||||
|
as part of this migration.
|
||||||
|
4. While Controller is stopped, take a matched, read-only rollback snapshot of
|
||||||
|
the old data directory and `/mnt/Media/YTPlayer`. Preserve permissions and
|
||||||
|
timestamps. Record a SHA-256 of `playlists.yaml`.
|
||||||
|
5. Create a dedicated v2 data directory. Copy, do not move, the legacy
|
||||||
|
`playlists.yaml` into its root before first v2 startup. The v2 directory must
|
||||||
|
not already contain `controller.sqlite3`, or automatic YAML import will not
|
||||||
|
run.
|
||||||
|
6. Create the secrets directory and, if needed, install the old cookies in it as
|
||||||
|
`yt-dlp-cookies.txt`. Do not bake cookies into the image.
|
||||||
|
7. Copy `.env.example` to `.env`; set a new long `FLASK_SECRET_KEY`, the dedicated
|
||||||
|
`CONTROLLER_DATA_DIR`, the shared `YTPLAYER_MEDIA_DIR`, secrets directory,
|
||||||
|
MQTT values, web port, and matching UID/GID. Review `config.yaml`, especially
|
||||||
|
Router policy and uncertainty deadline.
|
||||||
|
8. Validate and build the v2 deployment, then start it once. Watch logs for the
|
||||||
|
migration report or any startup failure.
|
||||||
|
9. Verify that the original YAML still exists, its full-hash `.pre-v2.*.bak`
|
||||||
|
exists, and `controller.sqlite3` exists. Confirm the source and backup hashes
|
||||||
|
match.
|
||||||
|
10. Check `/healthz`. Resolve or deliberately reconcile any review token; do not
|
||||||
|
equate a 200/degraded response with current Player or Router evidence.
|
||||||
|
11. Verify retained slot `title/state/count` values and all thirty HA discovery
|
||||||
|
IDs. Perform a controlled play/stop through `youtube/controller/cmd`, confirm
|
||||||
|
Player evidence drives state, and verify Router restoration/manual override
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
A concrete copy/start sequence, using the common legacy path, is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -o 1000 -g 1000 -m 0750 /srv/video-player-controller/data
|
||||||
|
sudo install -m 0640 -o 1000 -g 1000 \
|
||||||
|
/home/media/Scripts/Video-Player/playlists.yaml \
|
||||||
|
/srv/video-player-controller/data/playlists.yaml
|
||||||
|
sudo install -d -o root -g 1000 -m 0750 /srv/video-player-controller/secrets
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and config.yaml before continuing.
|
||||||
|
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose build --pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs --tail=200 controller
|
||||||
|
curl -fsS http://127.0.0.1:7531/healthz
|
||||||
|
|
||||||
|
sha256sum \
|
||||||
|
/srv/video-player-controller/data/playlists.yaml \
|
||||||
|
/srv/video-player-controller/data/playlists.yaml.pre-v2.*.bak
|
||||||
|
ls -l /srv/video-player-controller/data/controller.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
If first startup reports migration failure, stop the restart loop, retain the
|
||||||
|
original YAML and generated hash backup, inspect/fix a copy of the source, and
|
||||||
|
retry only while no final SQLite database exists. Returning to the recorded v1
|
||||||
|
image with the untouched v1 data/media snapshot remains safe because migration
|
||||||
|
does not modify them.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Code rollback and state rollback are different operations. Controller v2 does
|
||||||
|
not write changes back to `playlists.yaml`; v1 does not understand
|
||||||
|
`controller.sqlite3`. After any v2 download, delete, expiry, or slot reuse, the
|
||||||
|
pre-migration YAML and current media tree may no longer agree.
|
||||||
|
|
||||||
|
1. Stop Controller v2 cleanly. Leave Player Node unchanged.
|
||||||
|
2. Archive the complete v2 data directory, logs, `.env`, `config.yaml`, image ID,
|
||||||
|
and current media tree. Do not delete the SQLite database, original YAML, or
|
||||||
|
hash backup.
|
||||||
|
3. Restore the matched pre-upgrade v1 `playlists.yaml` **and media snapshot as a
|
||||||
|
pair** into the paths used by the old deployment. Do not point v1 at the live
|
||||||
|
v2 SQLite file or mix old YAML with media already mutated by v2.
|
||||||
|
4. Restore the recorded v1 image/commit, Compose file, cookie mount, environment,
|
||||||
|
and path layout.
|
||||||
|
5. Start only v1 and verify MQTT connectivity, retained stable-topic consumers,
|
||||||
|
submissions, and a controlled Player operation.
|
||||||
|
6. Keep the archived v2 database/media pair. A later roll-forward must restore
|
||||||
|
those together before starting v2 again.
|
||||||
|
|
||||||
|
If v2 has made no media or inventory mutation, the untouched original YAML and
|
||||||
|
pre-upgrade media may still be a valid rollback pair. Verify that fact rather
|
||||||
|
than assuming it. Controller v1 can compact and rename slots during prune or
|
||||||
|
delete, so starting it against mismatched/current v2 media can immediately undo
|
||||||
|
the stable-slot guarantee.
|
||||||
|
|
||||||
|
For a migration-only failure before final SQLite installation, simply keep v2
|
||||||
|
stopped and restart the recorded v1 deployment against the untouched original
|
||||||
|
data and media; no v2 database state needs conversion.
|
||||||
|
|
||||||
|
## Development and tests
|
||||||
|
|
||||||
|
Application imports are side-effect free. Constructing the Flask app does not
|
||||||
|
open SQLite, start MQTT, create directories, or launch threads. `wsgi.py` owns
|
||||||
|
the production lifecycle; `python app.py` explicitly owns the development
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
Create an isolated environment and run the complete regression suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install --upgrade pip
|
||||||
|
.venv/bin/python -m pip install --requirement requirements.txt
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite covers configuration and import side effects, atomic reservation and
|
||||||
|
stale generations, asynchronous deletion/retry/late-guard safety, migration
|
||||||
|
recovery/idempotence, MQTT startup/reconnect/PUBACK/manual acknowledgement,
|
||||||
|
Player generation, pathless busy/error handling and pause-timeout reduction,
|
||||||
|
durable playback plus connection-scoped cleanup reconciliation, Audio Router
|
||||||
|
timeouts and ownership loss, service integration, HA compatibility, HTTP
|
||||||
|
health, and Docker/runtime assets. It uses fakes and temporary directories; a
|
||||||
|
real-broker/container smoke test is still required for a deployment.
|
||||||
|
|
||||||
|
Useful static and container checks are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
curl -fsS http://127.0.0.1:7531/healthz
|
||||||
|
docker compose logs --tail=200 controller
|
||||||
|
```
|
||||||
|
|
||||||
|
The authoritative Player Node has its own isolated regression suite. Validate
|
||||||
|
the sibling without installing dependencies globally or modifying its source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../Video-Player-Node
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install --upgrade pip
|
||||||
|
.venv/bin/python -m pip install --requirement requirements.txt
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security and operational limitations
|
||||||
|
|
||||||
|
- The web form is unauthenticated and listens on `0.0.0.0` by default. Restrict
|
||||||
|
the published port with host firewall/network ACLs or place it behind an
|
||||||
|
authenticated reverse proxy. Submitted URLs must use `http`/`https`, carry no
|
||||||
|
embedded userinfo, and resolve (by exact, normalized hostname match) to an
|
||||||
|
official YouTube host: `youtube.com`, `www.youtube.com`, `m.youtube.com`,
|
||||||
|
`music.youtube.com`, or `youtu.be`. URL, submitter, note, and total request
|
||||||
|
size are also server-side bounded. yt-dlp performs the outbound request.
|
||||||
|
- MQTT username/password support is available, but application-level TLS is not
|
||||||
|
configured here. Keep this connection on a trusted/protected network or add a
|
||||||
|
separately managed secure tunnel/proxy, and grant the Controller only the
|
||||||
|
required publish/subscribe topic ACLs. In particular, restrict
|
||||||
|
`youtube/controller/cmd` and Router mode commands.
|
||||||
|
- Administrative reconciliation's `actor` is an audit label, not an identity
|
||||||
|
proof. Authorization comes from who can publish to the command topic.
|
||||||
|
- Direct commands to `youtube/player/cmd` bypass Controller orchestration and
|
||||||
|
can race its Router and deletion guards. Operational publishers should use
|
||||||
|
`youtube/controller/cmd` exclusively.
|
||||||
|
- Player command PUBACK is transport acknowledgement, not an application-level
|
||||||
|
correlated acknowledgement. Controller infers completion from authoritative
|
||||||
|
status/telemetry and serializes transitions; the Player protocol still has no
|
||||||
|
request ID or generation echoed in evidence.
|
||||||
|
- Audio Router's plain set/state topics have no publisher identity or atomic
|
||||||
|
compare-and-set. Manual override detection is deliberately conservative but
|
||||||
|
cannot eliminate a perfectly simultaneous same-value race.
|
||||||
|
- Optional v2 events are non-retained and can be missed while a consumer or MQTT
|
||||||
|
is offline. SQLite audit rows and durable playback reconciliation records are
|
||||||
|
the historical sources; the cleanup-only `/healthz` token is deliberately
|
||||||
|
connection-scoped and must be refetched. Existing consumers must not depend
|
||||||
|
on the event stream.
|
||||||
|
- Production is intentionally one Gunicorn worker with no preload or hot reload.
|
||||||
|
More workers would duplicate the coordinator, MQTT client, and downloader.
|
||||||
|
- Shutdown actively terminates tracked yt-dlp process groups (TERM, then KILL
|
||||||
|
after a short bound) rather than waiting for the configured 2-hour command
|
||||||
|
timeout. The current job fails instead of resuming. A container kill before
|
||||||
|
that bookkeeping completes is recovered as `interrupted`/`error`; queued
|
||||||
|
deletion work remains durably `deleting` for the evidence-gated retry path.
|
||||||
|
- The container is read-only and non-root, but the data and media mounts must be
|
||||||
|
writable. Controller can intentionally delete canonical expired or
|
||||||
|
administratively deleted slot directories; maintain independent backups.
|
||||||
|
- Python dependencies and yt-dlp are exactly pinned, but Docker base images are
|
||||||
|
tag-pinned rather than digest-pinned and Debian apt packages are not version
|
||||||
|
pinned. Builds are therefore not bit-for-bit reproducible. Pin verified image
|
||||||
|
digests and use a controlled package mirror if that is required.
|
||||||
|
- YouTube changes regularly. Update the explicit `yt-dlp[default,pin]` version
|
||||||
|
in `requirements.txt`, rebuild the image, and run both regression and download
|
||||||
|
smoke tests on a deliberate maintenance cadence. Runtime self-update is not
|
||||||
|
enabled. The copied Node binary must match the target image architecture and
|
||||||
|
remain supported by yt-dlp.
|
||||||
|
- SQLite and its `fcntl` process lock are designed for a local persistent
|
||||||
|
filesystem. Do not place `/controller-data` on storage whose locking or atomic
|
||||||
|
rename semantics are unreliable.
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
from flask import Flask, render_template_string, request, redirect, url_for, flash
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from controller_core import enqueue_download
|
from flask import Flask, flash, jsonify, redirect, render_template_string, request, url_for
|
||||||
|
|
||||||
app = Flask(__name__)
|
from controller.config import ControllerConfig, load_config
|
||||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret")
|
from controller.models import StoreError
|
||||||
|
from controller.service import ControllerService, ControllerUnavailableError
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
HTML_FORM = """
|
HTML_FORM = """
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -217,37 +224,164 @@ HTML_FORM = """
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@app.route("/", methods=["GET"])
|
ALLOWED_YOUTUBE_HOSTS = frozenset(
|
||||||
|
{
|
||||||
|
"youtube.com",
|
||||||
|
"www.youtube.com",
|
||||||
|
"m.youtube.com",
|
||||||
|
"music.youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_URL_LENGTH = 2048
|
||||||
|
MAX_SUBMITTER_LENGTH = 200
|
||||||
|
MAX_NOTE_LENGTH = 1000
|
||||||
|
MAX_REQUEST_CONTENT_LENGTH = 32 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_media_url(value: str) -> bool:
|
||||||
|
if not value or len(value) > MAX_URL_LENGTH:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
has_userinfo = parsed.username is not None or parsed.password is not None
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"}:
|
||||||
|
return False
|
||||||
|
if has_userinfo or not hostname:
|
||||||
|
return False
|
||||||
|
return hostname.lower() in ALLOWED_YOUTUBE_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def _submission_message(result: dict[str, object]) -> str:
|
||||||
|
status = result.get("status")
|
||||||
|
if status == "queue_full":
|
||||||
|
seconds = max(0, int(result.get("oldest_expires_in_seconds") or 0))
|
||||||
|
hours = max(1, (seconds + 3599) // 3600)
|
||||||
|
return f"Queue is full. Oldest playlist expires in about {hours} hour(s)."
|
||||||
|
if status == "slot_busy":
|
||||||
|
return f"Slot {result.get('slot')} is already being downloaded."
|
||||||
|
if status == "slot_protected":
|
||||||
|
return (
|
||||||
|
f"Slot {result.get('slot')} is protected by active or uncertain playback; "
|
||||||
|
"the playlist was not replaced."
|
||||||
|
)
|
||||||
|
if status == "shutting_down":
|
||||||
|
return "The Controller is shutting down; please try again shortly."
|
||||||
|
if status == "worker_error":
|
||||||
|
return "The playlist was reserved, but its download worker could not start."
|
||||||
|
if status != "queued":
|
||||||
|
return "The Controller could not queue that playlist; please try again."
|
||||||
|
|
||||||
|
slot = result.get("slot")
|
||||||
|
message = f"Playlist queued in slot {slot}."
|
||||||
|
if result.get("reused_slot"):
|
||||||
|
message += " (Existing slot – only new videos will be added.)"
|
||||||
|
else:
|
||||||
|
message += " It will start downloading shortly."
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(
|
||||||
|
config: ControllerConfig,
|
||||||
|
service: ControllerService,
|
||||||
|
*,
|
||||||
|
testing: bool = False,
|
||||||
|
) -> Flask:
|
||||||
|
"""Create the HTTP surface without starting MQTT, threads, or persistence."""
|
||||||
|
|
||||||
|
secret_key = os.environ.get("FLASK_SECRET_KEY")
|
||||||
|
if not secret_key and not testing:
|
||||||
|
raise RuntimeError("FLASK_SECRET_KEY must be set for the Controller web service")
|
||||||
|
|
||||||
|
web_app = Flask(__name__)
|
||||||
|
web_app.config.update(
|
||||||
|
TESTING=testing,
|
||||||
|
SECRET_KEY=secret_key or "controller-test-only-secret",
|
||||||
|
MAX_CONTENT_LENGTH=MAX_REQUEST_CONTENT_LENGTH,
|
||||||
|
)
|
||||||
|
web_app.extensions["controller_service"] = service
|
||||||
|
web_app.extensions["controller_config"] = config
|
||||||
|
|
||||||
|
@web_app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template_string(HTML_FORM)
|
return render_template_string(HTML_FORM)
|
||||||
|
|
||||||
|
@web_app.errorhandler(413)
|
||||||
|
def _submission_too_large(_error):
|
||||||
|
flash("The submission was too large.")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
@app.route("/submit", methods=["POST"])
|
@web_app.post("/submit")
|
||||||
def submit():
|
def submit():
|
||||||
url = request.form.get("url", "").strip()
|
url = request.form.get("url", "").strip()
|
||||||
submitter = request.form.get("submitter", "").strip() or None
|
submitter = request.form.get("submitter", "").strip() or None
|
||||||
note = request.form.get("note", "").strip() or None
|
note = request.form.get("note", "").strip() or None
|
||||||
|
|
||||||
if not url.startswith("http"):
|
if not _valid_media_url(url):
|
||||||
flash("Invalid URL.")
|
flash(
|
||||||
|
"Invalid URL. Only YouTube links are accepted "
|
||||||
|
"(e.g. https://www.youtube.com/... or https://youtu.be/...)."
|
||||||
|
)
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
if submitter is not None and len(submitter) > MAX_SUBMITTER_LENGTH:
|
||||||
|
flash(f"Your name must be {MAX_SUBMITTER_LENGTH} characters or fewer.")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
if note is not None and len(note) > MAX_NOTE_LENGTH:
|
||||||
|
flash(f"The note must be {MAX_NOTE_LENGTH} characters or fewer.")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
result = enqueue_download(url, submitter, note)
|
try:
|
||||||
|
result = service.enqueue_download(url, submitter, note)
|
||||||
if result.get("status") == "queue_full":
|
except ControllerUnavailableError as exc:
|
||||||
secs = result.get("oldest_expires_in_seconds", 0)
|
LOG.warning("playlist submission was rejected: %s", exc)
|
||||||
hours = max(1, secs // 3600)
|
flash("The Controller is unavailable; please try again shortly.")
|
||||||
flash(f"Queue is full. Oldest playlist expires in about {hours} hour(s).")
|
except StoreError as exc:
|
||||||
|
LOG.error("playlist submission hit a storage error: %s", exc)
|
||||||
|
flash("Controller storage is unavailable; the playlist was not queued.")
|
||||||
|
except ValueError as exc:
|
||||||
|
LOG.warning("playlist submission was rejected: %s", exc)
|
||||||
|
flash(f"The playlist request was rejected: {exc}")
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("playlist submission failed unexpectedly")
|
||||||
|
flash("The Controller could not queue that playlist; please try again.")
|
||||||
else:
|
else:
|
||||||
slot = result.get("slot")
|
flash(_submission_message(result))
|
||||||
msg = f"Playlist queued in slot {slot}."
|
|
||||||
if result.get("reused_slot"):
|
|
||||||
msg += " (Existing slot – only new videos will be added.)"
|
|
||||||
else:
|
|
||||||
msg += " It will start downloading shortly."
|
|
||||||
flash(msg)
|
|
||||||
|
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
@web_app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
try:
|
||||||
|
health = service.health()
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.exception("Controller health check failed")
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "unavailable",
|
||||||
|
"started": False,
|
||||||
|
"coordinator_alive": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
), 503
|
||||||
|
serving = bool(health.get("started") and health.get("coordinator_alive"))
|
||||||
|
return jsonify(health), 200 if serving else 503
|
||||||
|
|
||||||
|
return web_app
|
||||||
|
|
||||||
|
|
||||||
|
def _run_development_server() -> None:
|
||||||
|
config = load_config()
|
||||||
|
service = ControllerService(config)
|
||||||
|
web_app = create_app(config, service)
|
||||||
|
service.start()
|
||||||
|
try:
|
||||||
|
web_app.run(host=config.web.host, port=config.web.port)
|
||||||
|
finally:
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7531)))
|
_run_development_server()
|
||||||
|
|||||||
+60
-7
@@ -3,13 +3,66 @@ mqtt:
|
|||||||
port: 1883
|
port: 1883
|
||||||
username: null
|
username: null
|
||||||
password: null
|
password: null
|
||||||
client_id_prefix: "ytplayer"
|
client_id: yt-controller
|
||||||
|
keepalive_seconds: 30
|
||||||
|
reconnect_min_seconds: 1
|
||||||
|
reconnect_max_seconds: 30
|
||||||
|
command_queue_size: 512
|
||||||
|
# QoS 1 is accepted only after the broker PUBACKs within this bound.
|
||||||
|
publish_ack_timeout_seconds: 2
|
||||||
|
# Audio Router self-echo correlation stays claimable for this long after
|
||||||
|
# a Controller-originated mode-set publish is PUBACK'd, so a subscription
|
||||||
|
# echo that is delivered after the PUBACK is still recognized as ours
|
||||||
|
# instead of being mistaken for operator intent.
|
||||||
|
router_echo_grace_seconds: 2
|
||||||
|
|
||||||
topics:
|
topics:
|
||||||
cmd: "youtube/player/cmd"
|
player_command: youtube/player/cmd
|
||||||
status: "youtube/player/status"
|
player_status: youtube/player/status
|
||||||
|
player_state: youtube/player/state
|
||||||
|
player_telemetry: youtube/player/telemetry
|
||||||
|
controller_command: youtube/controller/cmd
|
||||||
|
ingest_progress: youtube/ingest/progress
|
||||||
|
playback_base: youtube/media/playback
|
||||||
|
router_mode_set: audio/player/mode/set
|
||||||
|
router_mode_state: audio/player/mode/state
|
||||||
|
# Optional additive result/warning topic. Set null to disable it.
|
||||||
|
# Existing consumers never depend on this topic.
|
||||||
|
controller_result: youtube/controller/v2/events
|
||||||
|
|
||||||
player:
|
storage:
|
||||||
display: ":0"
|
media_base: /mnt/Media/YTPlayer
|
||||||
xdg_runtime_dir: "/run/user/1000"
|
data_base: /controller-data
|
||||||
mpv_binary: "/usr/bin/mpv"
|
database_name: controller.sqlite3
|
||||||
|
legacy_yaml_name: playlists.yaml
|
||||||
|
slot_count: 10
|
||||||
|
retention_seconds: 172800
|
||||||
|
expiry_interval_seconds: 60
|
||||||
|
# Marks uncertain playback for operator review; it never removes protection.
|
||||||
|
uncertain_review_after_seconds: 900
|
||||||
|
|
||||||
|
downloads:
|
||||||
|
yt_dlp_binary: yt-dlp
|
||||||
|
cookie_path: /run/secrets/yt-dlp-cookies.txt
|
||||||
|
cookie_required: false
|
||||||
|
max_workers: 2
|
||||||
|
process_timeout_seconds: 7200
|
||||||
|
max_height: 1080
|
||||||
|
|
||||||
|
router:
|
||||||
|
required_mode: video
|
||||||
|
confirmation_timeout_seconds: 3
|
||||||
|
# attempt: request video and wait for the finite confirmation timeout
|
||||||
|
# continue: request video but do not wait when no state has been observed
|
||||||
|
# reject: reject playback until a Router state has been observed
|
||||||
|
when_state_unknown: attempt
|
||||||
|
continue_on_confirmation_failure: true
|
||||||
|
fallback_restore_mode: null
|
||||||
|
|
||||||
|
homeassistant:
|
||||||
|
enabled: true
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
|
||||||
|
web:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 7531
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""YTPlayer Controller v2 package."""
|
||||||
|
|
||||||
|
from .config import ControllerConfig, ConfigError, load_config
|
||||||
|
|
||||||
|
__all__ = ["ControllerConfig", "ConfigError", "load_config"]
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""Validated configuration for Controller v2."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .contracts import SLOT_IDS, Topics
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_CONFIG_PATH = BASE_DIR / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(ValueError):
|
||||||
|
"""Raised when Controller configuration is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MqttConfig:
|
||||||
|
host: str = "10.40.0.2"
|
||||||
|
port: int = 1883
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
client_id: str = "yt-controller"
|
||||||
|
keepalive_seconds: int = 30
|
||||||
|
reconnect_min_seconds: int = 1
|
||||||
|
reconnect_max_seconds: int = 30
|
||||||
|
command_queue_size: int = 512
|
||||||
|
publish_ack_timeout_seconds: float = 2.0
|
||||||
|
router_echo_grace_seconds: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StorageConfig:
|
||||||
|
media_base: Path = Path("/mnt/Media/YTPlayer")
|
||||||
|
data_base: Path = Path("/controller-data")
|
||||||
|
database_name: str = "controller.sqlite3"
|
||||||
|
legacy_yaml_name: str = "playlists.yaml"
|
||||||
|
retention_seconds: int = 48 * 3600
|
||||||
|
expiry_interval_seconds: float = 60.0
|
||||||
|
uncertain_review_after_seconds: float = 15 * 60.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_path(self) -> Path:
|
||||||
|
return self.data_base / self.database_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def legacy_yaml_path(self) -> Path:
|
||||||
|
return self.data_base / self.legacy_yaml_name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadConfig:
|
||||||
|
yt_dlp_binary: str = "yt-dlp"
|
||||||
|
cookie_path: Path | None = Path("/run/secrets/yt-dlp-cookies.txt")
|
||||||
|
cookie_required: bool = False
|
||||||
|
max_workers: int = 2
|
||||||
|
process_timeout_seconds: float = 2 * 3600.0
|
||||||
|
max_height: int = 1080
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouterConfig:
|
||||||
|
required_mode: str = "video"
|
||||||
|
confirmation_timeout_seconds: float = 3.0
|
||||||
|
when_state_unknown: str = "attempt"
|
||||||
|
continue_on_confirmation_failure: bool = True
|
||||||
|
fallback_restore_mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HomeAssistantConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
discovery_prefix: str = "homeassistant"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WebConfig:
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 7531
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ControllerConfig:
|
||||||
|
mqtt: MqttConfig = field(default_factory=MqttConfig)
|
||||||
|
topics: Topics = field(default_factory=Topics)
|
||||||
|
storage: StorageConfig = field(default_factory=StorageConfig)
|
||||||
|
downloads: DownloadConfig = field(default_factory=DownloadConfig)
|
||||||
|
router: RouterConfig = field(default_factory=RouterConfig)
|
||||||
|
homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
|
||||||
|
web: WebConfig = field(default_factory=WebConfig)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slot_ids(self) -> tuple[int, ...]:
|
||||||
|
return SLOT_IDS
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any, name: str) -> Mapping[str, Any]:
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ConfigError(f"{name} must be a mapping")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any, name: str, *, allow_none: bool = False) -> str | None:
|
||||||
|
if value is None and allow_none:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ConfigError(f"{name} must be a non-empty string")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: Any, name: str) -> str | None:
|
||||||
|
"""Normalize unset/blank optional environment values to ``None``."""
|
||||||
|
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
return None
|
||||||
|
return _text(value, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value: Any, name: str, *, minimum: int = 1, maximum: int | None = None) -> int:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ConfigError(f"{name} must be an integer")
|
||||||
|
try:
|
||||||
|
result = int(value)
|
||||||
|
except (TypeError, ValueError, OverflowError) as exc:
|
||||||
|
raise ConfigError(f"{name} must be an integer") from exc
|
||||||
|
if result < minimum or (maximum is not None and result > maximum):
|
||||||
|
upper = f" and <= {maximum}" if maximum is not None else ""
|
||||||
|
raise ConfigError(f"{name} must be >= {minimum}{upper}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, name: str, *, minimum: float = 0.0) -> float:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ConfigError(f"{name} must be a number")
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except (TypeError, ValueError, OverflowError) as exc:
|
||||||
|
raise ConfigError(f"{name} must be a number") from exc
|
||||||
|
if not math.isfinite(result):
|
||||||
|
raise ConfigError(f"{name} must be finite")
|
||||||
|
if result < minimum:
|
||||||
|
raise ConfigError(f"{name} must be >= {minimum}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean(value: Any, name: str) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise ConfigError(f"{name} must be a boolean")
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_path(value: Any, name: str) -> Path | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
text = _text(value, name)
|
||||||
|
assert text is not None
|
||||||
|
return Path(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any:
|
||||||
|
return env[key] if key in env else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any:
|
||||||
|
value = env.get(key)
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return fallback
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(
|
||||||
|
path: str | Path | None = None,
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
) -> ControllerConfig:
|
||||||
|
"""Load YAML relative to the repository and apply documented env overrides."""
|
||||||
|
|
||||||
|
config_path = Path(path) if path is not None else DEFAULT_CONFIG_PATH
|
||||||
|
raw: Mapping[str, Any] = {}
|
||||||
|
if config_path.exists():
|
||||||
|
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
raw = _mapping(loaded, "configuration")
|
||||||
|
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
mqtt_raw = _mapping(raw.get("mqtt"), "mqtt")
|
||||||
|
topics_raw = _mapping(raw.get("topics"), "topics")
|
||||||
|
storage_raw = _mapping(raw.get("storage"), "storage")
|
||||||
|
downloads_raw = _mapping(raw.get("downloads"), "downloads")
|
||||||
|
router_raw = _mapping(raw.get("router"), "router")
|
||||||
|
ha_raw = _mapping(raw.get("homeassistant"), "homeassistant")
|
||||||
|
web_raw = _mapping(raw.get("web"), "web")
|
||||||
|
|
||||||
|
mqtt = MqttConfig(
|
||||||
|
host=_text(_env_value(env, "MQTT_HOST", mqtt_raw.get("host", "10.40.0.2")), "mqtt.host"),
|
||||||
|
port=_integer(_env_value(env, "MQTT_PORT", mqtt_raw.get("port", 1883)), "mqtt.port", maximum=65535),
|
||||||
|
username=_optional_text(_optional_env_value(env, "MQTT_USERNAME", mqtt_raw.get("username")), "mqtt.username"),
|
||||||
|
password=_optional_text(_optional_env_value(env, "MQTT_PASSWORD", mqtt_raw.get("password")), "mqtt.password"),
|
||||||
|
client_id=_text(_env_value(env, "MQTT_CLIENT_ID", mqtt_raw.get("client_id", "yt-controller")), "mqtt.client_id"),
|
||||||
|
keepalive_seconds=_integer(mqtt_raw.get("keepalive_seconds", 30), "mqtt.keepalive_seconds"),
|
||||||
|
reconnect_min_seconds=_integer(mqtt_raw.get("reconnect_min_seconds", 1), "mqtt.reconnect_min_seconds"),
|
||||||
|
reconnect_max_seconds=_integer(mqtt_raw.get("reconnect_max_seconds", 30), "mqtt.reconnect_max_seconds"),
|
||||||
|
command_queue_size=_integer(mqtt_raw.get("command_queue_size", 512), "mqtt.command_queue_size"),
|
||||||
|
publish_ack_timeout_seconds=_number(
|
||||||
|
mqtt_raw.get("publish_ack_timeout_seconds", 2.0),
|
||||||
|
"mqtt.publish_ack_timeout_seconds",
|
||||||
|
minimum=0.1,
|
||||||
|
),
|
||||||
|
router_echo_grace_seconds=_number(
|
||||||
|
mqtt_raw.get("router_echo_grace_seconds", 2.0),
|
||||||
|
"mqtt.router_echo_grace_seconds",
|
||||||
|
minimum=0.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if mqtt.reconnect_max_seconds < mqtt.reconnect_min_seconds:
|
||||||
|
raise ConfigError("mqtt.reconnect_max_seconds must be >= reconnect_min_seconds")
|
||||||
|
if (mqtt.username is None) != (mqtt.password is None):
|
||||||
|
raise ConfigError("mqtt.username and mqtt.password must either both be set or both be null")
|
||||||
|
|
||||||
|
topics = Topics(
|
||||||
|
player_command=_text(topics_raw.get("player_command", "youtube/player/cmd"), "topics.player_command"),
|
||||||
|
player_status=_text(topics_raw.get("player_status", "youtube/player/status"), "topics.player_status"),
|
||||||
|
player_state=_text(topics_raw.get("player_state", "youtube/player/state"), "topics.player_state"),
|
||||||
|
player_telemetry=_text(topics_raw.get("player_telemetry", "youtube/player/telemetry"), "topics.player_telemetry"),
|
||||||
|
controller_command=_text(topics_raw.get("controller_command", "youtube/controller/cmd"), "topics.controller_command"),
|
||||||
|
ingest_progress=_text(topics_raw.get("ingest_progress", "youtube/ingest/progress"), "topics.ingest_progress"),
|
||||||
|
playback_base=_text(topics_raw.get("playback_base", "youtube/media/playback"), "topics.playback_base"),
|
||||||
|
router_mode_set=_text(topics_raw.get("router_mode_set", "audio/player/mode/set"), "topics.router_mode_set"),
|
||||||
|
router_mode_state=_text(topics_raw.get("router_mode_state", "audio/player/mode/state"), "topics.router_mode_state"),
|
||||||
|
controller_result=_text(
|
||||||
|
topics_raw.get("controller_result", "youtube/controller/v2/events"),
|
||||||
|
"topics.controller_result",
|
||||||
|
allow_none=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
configured_slots = _integer(storage_raw.get("slot_count", 10), "storage.slot_count")
|
||||||
|
if configured_slots != len(SLOT_IDS):
|
||||||
|
raise ConfigError("storage.slot_count is fixed at 10 for stable slot identities")
|
||||||
|
storage = StorageConfig(
|
||||||
|
media_base=Path(_text(_env_value(env, "YTPLAYER_MEDIA_BASE", storage_raw.get("media_base", "/mnt/Media/YTPlayer")), "storage.media_base")),
|
||||||
|
data_base=Path(_text(_env_value(env, "YTPLAYER_DATA_BASE", storage_raw.get("data_base", "/controller-data")), "storage.data_base")),
|
||||||
|
database_name=_text(storage_raw.get("database_name", "controller.sqlite3"), "storage.database_name"),
|
||||||
|
legacy_yaml_name=_text(storage_raw.get("legacy_yaml_name", "playlists.yaml"), "storage.legacy_yaml_name"),
|
||||||
|
retention_seconds=_integer(_env_value(env, "YTPLAYER_RETENTION_SECONDS", storage_raw.get("retention_seconds", 48 * 3600)), "storage.retention_seconds"),
|
||||||
|
expiry_interval_seconds=_number(storage_raw.get("expiry_interval_seconds", 60), "storage.expiry_interval_seconds", minimum=0.1),
|
||||||
|
uncertain_review_after_seconds=_number(storage_raw.get("uncertain_review_after_seconds", 15 * 60), "storage.uncertain_review_after_seconds", minimum=0.0),
|
||||||
|
)
|
||||||
|
if Path(storage.database_name).name != storage.database_name:
|
||||||
|
raise ConfigError("storage.database_name must be a filename")
|
||||||
|
if Path(storage.legacy_yaml_name).name != storage.legacy_yaml_name:
|
||||||
|
raise ConfigError("storage.legacy_yaml_name must be a filename")
|
||||||
|
if not storage.media_base.is_absolute():
|
||||||
|
raise ConfigError("storage.media_base must be an absolute path")
|
||||||
|
if not storage.data_base.is_absolute():
|
||||||
|
raise ConfigError("storage.data_base must be an absolute path")
|
||||||
|
|
||||||
|
cookie_raw = _env_value(env, "YTPLAYER_COOKIE_PATH", downloads_raw.get("cookie_path", "/run/secrets/yt-dlp-cookies.txt"))
|
||||||
|
downloads = DownloadConfig(
|
||||||
|
yt_dlp_binary=_text(downloads_raw.get("yt_dlp_binary", "yt-dlp"), "downloads.yt_dlp_binary"),
|
||||||
|
cookie_path=_optional_path(cookie_raw, "downloads.cookie_path"),
|
||||||
|
cookie_required=_boolean(downloads_raw.get("cookie_required", False), "downloads.cookie_required"),
|
||||||
|
max_workers=_integer(downloads_raw.get("max_workers", 2), "downloads.max_workers", maximum=10),
|
||||||
|
process_timeout_seconds=_number(downloads_raw.get("process_timeout_seconds", 2 * 3600), "downloads.process_timeout_seconds", minimum=1.0),
|
||||||
|
max_height=_integer(downloads_raw.get("max_height", 1080), "downloads.max_height"),
|
||||||
|
)
|
||||||
|
if downloads.cookie_required and downloads.cookie_path is None:
|
||||||
|
raise ConfigError("downloads.cookie_path is required when cookie_required is true")
|
||||||
|
if downloads.cookie_path is not None and not downloads.cookie_path.is_absolute():
|
||||||
|
raise ConfigError("downloads.cookie_path must be an absolute path")
|
||||||
|
|
||||||
|
unknown_behavior = _text(router_raw.get("when_state_unknown", "attempt"), "router.when_state_unknown")
|
||||||
|
if unknown_behavior not in {"attempt", "continue", "reject"}:
|
||||||
|
raise ConfigError("router.when_state_unknown must be attempt, continue, or reject")
|
||||||
|
fallback = _text(router_raw.get("fallback_restore_mode"), "router.fallback_restore_mode", allow_none=True)
|
||||||
|
router = RouterConfig(
|
||||||
|
required_mode=_text(router_raw.get("required_mode", "video"), "router.required_mode"),
|
||||||
|
confirmation_timeout_seconds=_number(router_raw.get("confirmation_timeout_seconds", 3.0), "router.confirmation_timeout_seconds", minimum=0.1),
|
||||||
|
when_state_unknown=unknown_behavior,
|
||||||
|
continue_on_confirmation_failure=_boolean(router_raw.get("continue_on_confirmation_failure", True), "router.continue_on_confirmation_failure"),
|
||||||
|
fallback_restore_mode=fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
homeassistant = HomeAssistantConfig(
|
||||||
|
enabled=_boolean(ha_raw.get("enabled", True), "homeassistant.enabled"),
|
||||||
|
discovery_prefix=_text(ha_raw.get("discovery_prefix", "homeassistant"), "homeassistant.discovery_prefix"),
|
||||||
|
)
|
||||||
|
web = WebConfig(
|
||||||
|
host=_text(web_raw.get("host", "0.0.0.0"), "web.host"),
|
||||||
|
port=_integer(_env_value(env, "PORT", web_raw.get("port", 7531)), "web.port", maximum=65535),
|
||||||
|
)
|
||||||
|
return ControllerConfig(
|
||||||
|
mqtt=mqtt,
|
||||||
|
topics=topics,
|
||||||
|
storage=storage,
|
||||||
|
downloads=downloads,
|
||||||
|
router=router,
|
||||||
|
homeassistant=homeassistant,
|
||||||
|
web=web,
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Stable MQTT contracts shared by the Controller adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
SLOT_IDS = tuple(range(1, 11))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Topics:
|
||||||
|
"""MQTT topic names.
|
||||||
|
|
||||||
|
Defaults preserve the established Player, Home Assistant, and Companion
|
||||||
|
integrations. Player ``state`` is fixed by the Player Node contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
player_command: str = "youtube/player/cmd"
|
||||||
|
player_status: str = "youtube/player/status"
|
||||||
|
player_state: str = "youtube/player/state"
|
||||||
|
player_telemetry: str = "youtube/player/telemetry"
|
||||||
|
controller_command: str = "youtube/controller/cmd"
|
||||||
|
ingest_progress: str = "youtube/ingest/progress"
|
||||||
|
playback_base: str = "youtube/media/playback"
|
||||||
|
router_mode_set: str = "audio/player/mode/set"
|
||||||
|
router_mode_state: str = "audio/player/mode/state"
|
||||||
|
controller_result: str | None = "youtube/controller/v2/events"
|
||||||
|
|
||||||
|
def slot_base(self, slot: int) -> str:
|
||||||
|
if slot not in SLOT_IDS:
|
||||||
|
raise ValueError(f"slot must be in 1..10, got {slot!r}")
|
||||||
|
return f"{self.playback_base}/{slot}"
|
||||||
|
|
||||||
|
|
||||||
|
PLAYER_COMMAND_QOS = 1
|
||||||
|
PLAYER_COMMAND_RETAIN = False
|
||||||
|
PLAYER_STATUS_QOS = 1
|
||||||
|
PLAYER_STATE_QOS = 0
|
||||||
|
PLAYER_TELEMETRY_QOS = 0
|
||||||
|
|
||||||
|
# Existing Controller-owned HA/Companion state was retained at QoS 0.
|
||||||
|
LEGACY_STATE_QOS = 0
|
||||||
|
LEGACY_STATE_RETAIN = True
|
||||||
|
|
||||||
|
# Router commands are ephemeral commands just like Player commands.
|
||||||
|
ROUTER_COMMAND_QOS = 1
|
||||||
|
ROUTER_COMMAND_RETAIN = False
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
"""Bounded, generation-safe media downloading and canonical slot cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .config import ControllerConfig
|
||||||
|
from .contracts import SLOT_IDS
|
||||||
|
from .models import DeleteToken, DownloadReservation, SlotProtectedError, StaleOperationError
|
||||||
|
from .store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
MEDIA_EXTENSIONS = frozenset({".mp4", ".mkv", ".webm", ".mov", ".m4v"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadEvent:
|
||||||
|
slot: int
|
||||||
|
state: str
|
||||||
|
title: str | None = None
|
||||||
|
count: int | None = None
|
||||||
|
message: str | None = None
|
||||||
|
job_id: str | None = None
|
||||||
|
generation: int | None = None
|
||||||
|
url: str | None = None
|
||||||
|
submitter: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
reused_slot: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
EventCallback = Callable[[DownloadEvent], None]
|
||||||
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_slot_directory(media_base: Path, slot: int) -> Path:
|
||||||
|
if slot not in SLOT_IDS:
|
||||||
|
raise ValueError(f"slot must be in 1..10, got {slot!r}")
|
||||||
|
return media_base / f"slot{slot}"
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_playlist_path(media_base: Path, slot: int) -> Path:
|
||||||
|
return canonical_slot_directory(media_base, slot) / "playlist.m3u"
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_filename(path: Path) -> str:
|
||||||
|
stem = path.stem
|
||||||
|
if " - " in stem:
|
||||||
|
stem = stem.split(" - ", 1)[1]
|
||||||
|
if " [" in stem:
|
||||||
|
stem = stem.rsplit(" [", 1)[0]
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager:
|
||||||
|
"""Reserve synchronously, then perform work on a bounded executor."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ControllerConfig,
|
||||||
|
store: ControllerStore,
|
||||||
|
*,
|
||||||
|
on_event: EventCallback | None = None,
|
||||||
|
runner: Runner | None = None,
|
||||||
|
executor: ThreadPoolExecutor | None = None,
|
||||||
|
):
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self.on_event = on_event or (lambda event: None)
|
||||||
|
self.runner = runner
|
||||||
|
self._executor = executor or ThreadPoolExecutor(
|
||||||
|
max_workers=config.downloads.max_workers,
|
||||||
|
thread_name_prefix="yt-download",
|
||||||
|
)
|
||||||
|
self._owns_executor = executor is None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._futures: set[Future[Any]] = set()
|
||||||
|
self._processes: set[subprocess.Popen[str]] = set()
|
||||||
|
self._deletions_in_flight: set[tuple[int, int]] = set()
|
||||||
|
self._stopping = False
|
||||||
|
|
||||||
|
def enqueue(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
submitter: str | None,
|
||||||
|
note: str | None,
|
||||||
|
) -> DownloadReservation:
|
||||||
|
with self._lock:
|
||||||
|
if self._stopping:
|
||||||
|
return DownloadReservation(status="shutting_down")
|
||||||
|
reservation = self.store.reserve_download(url, submitter, note)
|
||||||
|
if reservation.status != "reserved":
|
||||||
|
return reservation
|
||||||
|
assert reservation.slot is not None
|
||||||
|
assert reservation.job_id is not None
|
||||||
|
assert reservation.generation is not None
|
||||||
|
|
||||||
|
# Publish/record queued after the transaction commits and before a
|
||||||
|
# worker can publish downloading or ready.
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=reservation.slot,
|
||||||
|
state="queued",
|
||||||
|
job_id=reservation.job_id,
|
||||||
|
generation=reservation.generation,
|
||||||
|
url=url,
|
||||||
|
submitter=submitter,
|
||||||
|
note=note,
|
||||||
|
reused_slot=reservation.reused_slot,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
future = self._executor.submit(
|
||||||
|
self._download,
|
||||||
|
url,
|
||||||
|
reservation.slot,
|
||||||
|
reservation.generation,
|
||||||
|
reservation.job_id,
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
message = f"could not start download worker: {exc}"
|
||||||
|
self.store.fail_download(
|
||||||
|
reservation.job_id,
|
||||||
|
reservation.slot,
|
||||||
|
reservation.generation,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=reservation.slot,
|
||||||
|
state="error",
|
||||||
|
message=message,
|
||||||
|
job_id=reservation.job_id,
|
||||||
|
generation=reservation.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DownloadReservation(
|
||||||
|
status="worker_error",
|
||||||
|
slot=reservation.slot,
|
||||||
|
generation=reservation.generation,
|
||||||
|
job_id=reservation.job_id,
|
||||||
|
reused_slot=reservation.reused_slot,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._futures.add(future)
|
||||||
|
future.add_done_callback(self._forget_future)
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
def _forget_future(self, future: Future[Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._futures.discard(future)
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("download worker escaped its failure boundary")
|
||||||
|
|
||||||
|
def _emit(self, event: DownloadEvent) -> None:
|
||||||
|
try:
|
||||||
|
self.on_event(event)
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("download event callback failed")
|
||||||
|
|
||||||
|
def _working_cookie_copy(self) -> Path | None:
|
||||||
|
source = self.config.downloads.cookie_path
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
if source.is_file():
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix="ytplayer-cookies-",
|
||||||
|
suffix=".txt",
|
||||||
|
)
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as destination:
|
||||||
|
with source.open("rb") as source_file:
|
||||||
|
shutil.copyfileobj(source_file, destination)
|
||||||
|
destination.flush()
|
||||||
|
os.fsync(destination.fileno())
|
||||||
|
temporary_path.chmod(0o600)
|
||||||
|
return temporary_path
|
||||||
|
except BaseException:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
if self.config.downloads.cookie_required:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"required yt-dlp cookie file is missing: {source}"
|
||||||
|
)
|
||||||
|
LOG.warning(
|
||||||
|
"yt-dlp cookie file is unavailable; continuing without it: %s",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _yt_dlp_arguments(self) -> Iterator[list[str]]:
|
||||||
|
working_cookie = self._working_cookie_copy()
|
||||||
|
arguments = [
|
||||||
|
self.config.downloads.yt_dlp_binary,
|
||||||
|
"--ignore-config",
|
||||||
|
"--no-js-runtimes",
|
||||||
|
"--js-runtimes",
|
||||||
|
"node",
|
||||||
|
"--cache-dir",
|
||||||
|
"/tmp/yt-dlp-cache",
|
||||||
|
]
|
||||||
|
if working_cookie is not None:
|
||||||
|
arguments.extend(("--cookies", str(working_cookie)))
|
||||||
|
try:
|
||||||
|
yield arguments
|
||||||
|
finally:
|
||||||
|
if working_cookie is not None:
|
||||||
|
working_cookie.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _get_title(self, url: str) -> tuple[str, str | None]:
|
||||||
|
try:
|
||||||
|
with self._yt_dlp_arguments() as base_arguments:
|
||||||
|
arguments = base_arguments + [
|
||||||
|
"--flat-playlist",
|
||||||
|
"--print",
|
||||||
|
"%(playlist_title)s|||%(title)s|||%(id)s",
|
||||||
|
"--playlist-end",
|
||||||
|
"1",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
result = self._run_command(
|
||||||
|
arguments,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=min(
|
||||||
|
120.0,
|
||||||
|
self.config.downloads.process_timeout_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
line = (result.stdout or "").strip().splitlines()[0]
|
||||||
|
playlist_title, _, remainder = line.partition("|||")
|
||||||
|
video_title, _, yt_id = remainder.partition("|||")
|
||||||
|
playlist_title = playlist_title.strip()
|
||||||
|
video_title = video_title.strip()
|
||||||
|
title = (
|
||||||
|
playlist_title
|
||||||
|
if playlist_title and playlist_title.upper() not in {"NA", "NONE"}
|
||||||
|
else video_title
|
||||||
|
)
|
||||||
|
return title or url, yt_id.strip() or None
|
||||||
|
except (IndexError, OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||||
|
LOG.warning("could not determine media title for %s: %s", url, exc)
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
def _download(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
slot: int,
|
||||||
|
generation: int,
|
||||||
|
job_id: str,
|
||||||
|
) -> None:
|
||||||
|
if not self.store.mark_download_running(job_id, slot, generation):
|
||||||
|
return
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=slot,
|
||||||
|
state="downloading",
|
||||||
|
job_id=job_id,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
slot_directory = canonical_slot_directory(self.config.storage.media_base, slot)
|
||||||
|
temporary_playlist = slot_directory / f".playlist.{job_id}.tmp"
|
||||||
|
try:
|
||||||
|
slot_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
if slot_directory.is_symlink():
|
||||||
|
raise RuntimeError(f"refusing to download through symlink: {slot_directory}")
|
||||||
|
title, yt_id = self._get_title(url)
|
||||||
|
with self._yt_dlp_arguments() as base_arguments:
|
||||||
|
arguments = base_arguments + [
|
||||||
|
"--yes-playlist",
|
||||||
|
"--no-overwrites",
|
||||||
|
"--no-progress",
|
||||||
|
"-o",
|
||||||
|
str(
|
||||||
|
slot_directory
|
||||||
|
/ "%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"
|
||||||
|
),
|
||||||
|
"-f",
|
||||||
|
(
|
||||||
|
f"bv*[height<={self.config.downloads.max_height}]+ba/"
|
||||||
|
f"b[height<={self.config.downloads.max_height}]/b"
|
||||||
|
),
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
self._run_command(
|
||||||
|
arguments,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self.config.downloads.process_timeout_seconds,
|
||||||
|
)
|
||||||
|
media_files = sorted(
|
||||||
|
path
|
||||||
|
for path in slot_directory.iterdir()
|
||||||
|
if path.is_file() and path.suffix.lower() in MEDIA_EXTENSIONS
|
||||||
|
)
|
||||||
|
if not media_files:
|
||||||
|
raise RuntimeError("yt-dlp completed without producing media files")
|
||||||
|
|
||||||
|
items = [
|
||||||
|
(title_from_filename(path), os.path.relpath(path, slot_directory))
|
||||||
|
for path in media_files
|
||||||
|
]
|
||||||
|
with temporary_playlist.open("w", encoding="utf-8", newline="\n") as stream:
|
||||||
|
for _, relative_path in items:
|
||||||
|
stream.write(relative_path + "\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(
|
||||||
|
temporary_playlist,
|
||||||
|
canonical_playlist_path(self.config.storage.media_base, slot),
|
||||||
|
)
|
||||||
|
self._fsync_directory(slot_directory)
|
||||||
|
|
||||||
|
if not self.store.complete_download(
|
||||||
|
job_id,
|
||||||
|
slot,
|
||||||
|
generation,
|
||||||
|
title=title,
|
||||||
|
items=items,
|
||||||
|
yt_id=yt_id,
|
||||||
|
):
|
||||||
|
LOG.warning(
|
||||||
|
"ignored stale download completion job=%s slot=%s generation=%s",
|
||||||
|
job_id,
|
||||||
|
slot,
|
||||||
|
generation,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=slot,
|
||||||
|
state="ready",
|
||||||
|
title=title,
|
||||||
|
count=len(items),
|
||||||
|
job_id=job_id,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
temporary_playlist.unlink(missing_ok=True)
|
||||||
|
message = self._failure_message(exc)
|
||||||
|
self.store.fail_download(job_id, slot, generation, message)
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=slot,
|
||||||
|
state="error",
|
||||||
|
message=message,
|
||||||
|
job_id=job_id,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failure_message(exc: BaseException) -> str:
|
||||||
|
if isinstance(exc, subprocess.TimeoutExpired):
|
||||||
|
return f"yt-dlp timed out after {exc.timeout} seconds"
|
||||||
|
if isinstance(exc, subprocess.CalledProcessError):
|
||||||
|
return f"yt-dlp failed with exit code {exc.returncode}"
|
||||||
|
return f"download failed: {exc}"
|
||||||
|
|
||||||
|
def _run_command(
|
||||||
|
self,
|
||||||
|
arguments: list[str],
|
||||||
|
*,
|
||||||
|
check: bool,
|
||||||
|
text: bool,
|
||||||
|
timeout: float,
|
||||||
|
capture_output: bool = False,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
if self.runner is not None:
|
||||||
|
return self.runner(
|
||||||
|
arguments,
|
||||||
|
check=check,
|
||||||
|
text=text,
|
||||||
|
timeout=timeout,
|
||||||
|
capture_output=capture_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._stopping:
|
||||||
|
raise RuntimeError("Controller is shutting down")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
arguments,
|
||||||
|
stdout=subprocess.PIPE if capture_output else None,
|
||||||
|
stderr=subprocess.PIPE if capture_output else None,
|
||||||
|
text=text,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
self._processes.add(process)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._terminate_process(process, force=False)
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate(timeout=2.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._terminate_process(process, force=True)
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
raise subprocess.TimeoutExpired(
|
||||||
|
arguments,
|
||||||
|
timeout,
|
||||||
|
output=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
)
|
||||||
|
completed = subprocess.CompletedProcess(
|
||||||
|
arguments,
|
||||||
|
process.returncode,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
)
|
||||||
|
if check and process.returncode:
|
||||||
|
raise subprocess.CalledProcessError(
|
||||||
|
process.returncode,
|
||||||
|
arguments,
|
||||||
|
output=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
)
|
||||||
|
return completed
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._processes.discard(process)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _terminate_process(
|
||||||
|
process: subprocess.Popen[str],
|
||||||
|
*,
|
||||||
|
force: bool,
|
||||||
|
) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(
|
||||||
|
process.pid,
|
||||||
|
signal.SIGKILL if force else signal.SIGTERM,
|
||||||
|
)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
process.kill() if force else process.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
def delete_slot(
|
||||||
|
self,
|
||||||
|
slot: int,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
expected_url: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
token = self.store.begin_delete(slot, reason=reason, expected_url=expected_url)
|
||||||
|
return self.finish_deletion(token)
|
||||||
|
|
||||||
|
def finish_deletion(self, token: DeleteToken) -> bool:
|
||||||
|
slot_directory = canonical_slot_directory(self.config.storage.media_base, token.slot)
|
||||||
|
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||||
|
trash_path: Path | None = None
|
||||||
|
if self._slot_is_guarded(token.slot):
|
||||||
|
return False
|
||||||
|
if os.path.lexists(slot_directory):
|
||||||
|
if slot_directory.is_symlink():
|
||||||
|
slot_directory.unlink()
|
||||||
|
else:
|
||||||
|
trash_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._fsync_directory(self.config.storage.media_base)
|
||||||
|
trash_path = trash_root / (
|
||||||
|
f"slot{token.slot}.generation{token.generation}.{uuid.uuid4().hex}"
|
||||||
|
)
|
||||||
|
os.replace(slot_directory, trash_path)
|
||||||
|
self._fsync_directory(self.config.storage.media_base)
|
||||||
|
self._fsync_directory(trash_root)
|
||||||
|
if self._slot_is_guarded(token.slot):
|
||||||
|
if (
|
||||||
|
trash_path is not None
|
||||||
|
and not os.path.lexists(slot_directory)
|
||||||
|
and os.path.lexists(trash_path)
|
||||||
|
):
|
||||||
|
os.replace(trash_path, slot_directory)
|
||||||
|
self._fsync_directory(self.config.storage.media_base)
|
||||||
|
self._fsync_directory(trash_root)
|
||||||
|
return False
|
||||||
|
if trash_path is not None:
|
||||||
|
shutil.rmtree(trash_path)
|
||||||
|
self._fsync_directory(trash_root)
|
||||||
|
return self.store.finish_delete(token)
|
||||||
|
|
||||||
|
def _slot_is_guarded(self, slot: int) -> bool:
|
||||||
|
return any(
|
||||||
|
guard.slot == slot for guard in self.store.list_live_guards()
|
||||||
|
)
|
||||||
|
|
||||||
|
def expire_due_slots(self) -> list[int]:
|
||||||
|
"""Synchronously expire slots for maintenance callers and tests."""
|
||||||
|
|
||||||
|
deleted: list[int] = []
|
||||||
|
for token in self.store.begin_expired_deletions():
|
||||||
|
try:
|
||||||
|
if self.finish_deletion(token):
|
||||||
|
deleted.append(token.slot)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception("could not finish expired deletion for slot %s", token.slot)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def schedule_delete_slot(
|
||||||
|
self,
|
||||||
|
slot: int,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
expected_url: str | None = None,
|
||||||
|
) -> DeleteToken:
|
||||||
|
"""Atomically fence a manual deletion, then finish it off-thread."""
|
||||||
|
|
||||||
|
token = self.store.begin_delete(
|
||||||
|
slot,
|
||||||
|
reason=reason,
|
||||||
|
expected_url=expected_url,
|
||||||
|
)
|
||||||
|
self._schedule_deletion(token)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def schedule_expired_deletions(self) -> list[DeleteToken]:
|
||||||
|
"""Fence all currently due slots and queue filesystem work."""
|
||||||
|
|
||||||
|
tokens = self.store.begin_expired_deletions()
|
||||||
|
for token in tokens:
|
||||||
|
self._schedule_deletion(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def recover_pending_deletions(self) -> list[int]:
|
||||||
|
recovered: list[int] = []
|
||||||
|
for token in self.store.pending_deletions():
|
||||||
|
if self._recover_deletion(token):
|
||||||
|
recovered.append(token.slot)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
def schedule_pending_deletion_recovery(self) -> bool:
|
||||||
|
"""Queue every safe pending deletion without blocking the coordinator."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._stopping:
|
||||||
|
return False
|
||||||
|
scheduled = False
|
||||||
|
for token in self.store.pending_deletions():
|
||||||
|
scheduled = self._schedule_deletion(token) or scheduled
|
||||||
|
return scheduled
|
||||||
|
|
||||||
|
def _schedule_deletion(self, token: DeleteToken) -> bool:
|
||||||
|
key = (token.slot, token.generation)
|
||||||
|
with self._lock:
|
||||||
|
if self._stopping or key in self._deletions_in_flight:
|
||||||
|
return False
|
||||||
|
self._deletions_in_flight.add(key)
|
||||||
|
try:
|
||||||
|
future = self._executor.submit(self._deletion_worker, token)
|
||||||
|
except BaseException as exc:
|
||||||
|
with self._lock:
|
||||||
|
self._deletions_in_flight.discard(key)
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=token.slot,
|
||||||
|
state="delete_error",
|
||||||
|
message=f"could not start deletion worker: {exc}",
|
||||||
|
generation=token.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
self._futures.add(future)
|
||||||
|
future.add_done_callback(self._forget_future)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _deletion_worker(self, token: DeleteToken) -> None:
|
||||||
|
key = (token.slot, token.generation)
|
||||||
|
try:
|
||||||
|
if self._recover_deletion(token):
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=token.slot,
|
||||||
|
state="deleted",
|
||||||
|
generation=token.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif self.store.get_slot(token.slot).media_state == "deleting":
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=token.slot,
|
||||||
|
state="delete_deferred",
|
||||||
|
message="slot deletion was deferred by playback protection",
|
||||||
|
generation=token.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
LOG.exception(
|
||||||
|
"could not finish deletion for slot %s generation %s",
|
||||||
|
token.slot,
|
||||||
|
token.generation,
|
||||||
|
)
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=token.slot,
|
||||||
|
state="delete_error",
|
||||||
|
message=f"deletion remains pending: {exc}",
|
||||||
|
generation=token.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._deletions_in_flight.discard(key)
|
||||||
|
|
||||||
|
def _recover_deletion(self, token: DeleteToken) -> bool:
|
||||||
|
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||||
|
slot_directory = canonical_slot_directory(
|
||||||
|
self.config.storage.media_base,
|
||||||
|
token.slot,
|
||||||
|
)
|
||||||
|
if self._slot_is_guarded(token.slot):
|
||||||
|
LOG.warning(
|
||||||
|
"deferring interrupted deletion for protected slot %s",
|
||||||
|
token.slot,
|
||||||
|
)
|
||||||
|
self._restore_guarded_trash(token, trash_root, slot_directory)
|
||||||
|
return False
|
||||||
|
if trash_root.is_dir():
|
||||||
|
prefix = f"slot{token.slot}.generation{token.generation}."
|
||||||
|
for candidate in trash_root.iterdir():
|
||||||
|
if not candidate.name.startswith(prefix):
|
||||||
|
continue
|
||||||
|
if self._slot_is_guarded(token.slot):
|
||||||
|
self._restore_guarded_trash(
|
||||||
|
token,
|
||||||
|
trash_root,
|
||||||
|
slot_directory,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if candidate.is_symlink() or candidate.is_file():
|
||||||
|
candidate.unlink()
|
||||||
|
elif candidate.is_dir():
|
||||||
|
shutil.rmtree(candidate)
|
||||||
|
self._fsync_directory(trash_root)
|
||||||
|
return self.finish_deletion(token)
|
||||||
|
|
||||||
|
def _restore_guarded_trash(
|
||||||
|
self,
|
||||||
|
token: DeleteToken,
|
||||||
|
trash_root: Path,
|
||||||
|
slot_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
if not trash_root.is_dir() or os.path.lexists(slot_directory):
|
||||||
|
return
|
||||||
|
prefix = f"slot{token.slot}.generation{token.generation}."
|
||||||
|
candidates = sorted(
|
||||||
|
candidate
|
||||||
|
for candidate in trash_root.iterdir()
|
||||||
|
if candidate.name.startswith(prefix)
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
os.replace(candidates[0], slot_directory)
|
||||||
|
self._fsync_directory(self.config.storage.media_base)
|
||||||
|
self._fsync_directory(trash_root)
|
||||||
|
|
||||||
|
def shutdown(self, *, wait: bool = True) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._stopping = True
|
||||||
|
processes = tuple(self._processes)
|
||||||
|
for process in processes:
|
||||||
|
self._terminate_process(process, force=False)
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
for process in processes:
|
||||||
|
if process.poll() is not None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._terminate_process(process, force=True)
|
||||||
|
if self._owns_executor:
|
||||||
|
self._executor.shutdown(wait=wait, cancel_futures=True)
|
||||||
|
|
||||||
|
def active_future_count(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._futures)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Home Assistant discovery and legacy slot-publication helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
from .config import HomeAssistantConfig
|
||||||
|
from .contracts import LEGACY_STATE_QOS, LEGACY_STATE_RETAIN, SLOT_IDS, Topics
|
||||||
|
|
||||||
|
|
||||||
|
Publish = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
HA_DEVICE = {
|
||||||
|
"identifiers": ["ytplayer-controller"],
|
||||||
|
"name": "YouTube Controller",
|
||||||
|
"manufacturer": "River of Glory",
|
||||||
|
"model": "YT Controller",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SlotAnnouncement:
|
||||||
|
slot: int
|
||||||
|
title: str
|
||||||
|
state: str
|
||||||
|
count: int
|
||||||
|
items: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_messages(
|
||||||
|
topics: Topics,
|
||||||
|
config: HomeAssistantConfig,
|
||||||
|
) -> Iterable[tuple[str, str, int, bool]]:
|
||||||
|
"""Yield the existing thirty discovery documents unchanged."""
|
||||||
|
|
||||||
|
for slot in SLOT_IDS:
|
||||||
|
base = topics.slot_base(slot)
|
||||||
|
object_id = f"yt_slot_{slot}"
|
||||||
|
entities = (
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
{
|
||||||
|
"name": f"YT Slot {slot} Title",
|
||||||
|
"state_topic": f"{base}/title",
|
||||||
|
"unique_id": f"{object_id}_title",
|
||||||
|
"icon": "mdi:playlist-music",
|
||||||
|
"device": HA_DEVICE,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"state",
|
||||||
|
{
|
||||||
|
"name": f"YT Slot {slot} State",
|
||||||
|
"state_topic": f"{base}/state",
|
||||||
|
"unique_id": f"{object_id}_state",
|
||||||
|
"icon": "mdi:circle-small",
|
||||||
|
"device": HA_DEVICE,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"count",
|
||||||
|
{
|
||||||
|
"name": f"YT Slot {slot} Count",
|
||||||
|
"state_topic": f"{base}/count",
|
||||||
|
"unique_id": f"{object_id}_count",
|
||||||
|
"icon": "mdi:numeric",
|
||||||
|
"device": HA_DEVICE,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for suffix, payload in entities:
|
||||||
|
yield (
|
||||||
|
f"{config.discovery_prefix}/sensor/{object_id}_{suffix}/config",
|
||||||
|
json.dumps(payload),
|
||||||
|
LEGACY_STATE_QOS,
|
||||||
|
LEGACY_STATE_RETAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def slot_messages(
|
||||||
|
announcement: SlotAnnouncement,
|
||||||
|
topics: Topics,
|
||||||
|
*,
|
||||||
|
clear_items_through: int = 0,
|
||||||
|
) -> Iterable[tuple[str, str, int, bool]]:
|
||||||
|
"""Yield retained legacy slot topics, including stale item cleanup."""
|
||||||
|
|
||||||
|
base = topics.slot_base(announcement.slot)
|
||||||
|
yield f"{base}/title", announcement.title, LEGACY_STATE_QOS, LEGACY_STATE_RETAIN
|
||||||
|
yield f"{base}/state", announcement.state, LEGACY_STATE_QOS, LEGACY_STATE_RETAIN
|
||||||
|
yield f"{base}/count", str(announcement.count), LEGACY_STATE_QOS, LEGACY_STATE_RETAIN
|
||||||
|
for index, title in enumerate(announcement.items, start=1):
|
||||||
|
yield f"{base}/items/{index}", title, LEGACY_STATE_QOS, LEGACY_STATE_RETAIN
|
||||||
|
for index in range(len(announcement.items) + 1, clear_items_through + 1):
|
||||||
|
yield f"{base}/items/{index}", "", LEGACY_STATE_QOS, LEGACY_STATE_RETAIN
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Controller domain records shared by persistence and orchestration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
MEDIA_STATES = frozenset(
|
||||||
|
{"empty", "queued", "downloading", "ready", "error", "deleting"}
|
||||||
|
)
|
||||||
|
LIVE_JOB_STATES = frozenset({"reserved", "running"})
|
||||||
|
LIVE_GUARD_STATES = frozenset({"active", "review_required"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SlotRecord:
|
||||||
|
slot: int
|
||||||
|
generation: int
|
||||||
|
media_state: str
|
||||||
|
url: str | None
|
||||||
|
yt_id: str | None
|
||||||
|
title: str
|
||||||
|
item_count: int
|
||||||
|
published_item_count: int
|
||||||
|
created_at: float | None
|
||||||
|
ready_at: float | None
|
||||||
|
expires_at: float | None
|
||||||
|
last_error: str | None
|
||||||
|
updated_at: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadReservation:
|
||||||
|
status: str
|
||||||
|
slot: int | None = None
|
||||||
|
generation: int | None = None
|
||||||
|
job_id: str | None = None
|
||||||
|
reused_slot: bool = False
|
||||||
|
oldest_expires_at: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadJob:
|
||||||
|
job_id: str
|
||||||
|
slot: int
|
||||||
|
generation: int
|
||||||
|
url: str
|
||||||
|
status: str
|
||||||
|
submitter: str | None
|
||||||
|
note: str | None
|
||||||
|
requested_at: float
|
||||||
|
started_at: float | None
|
||||||
|
finished_at: float | None
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeleteToken:
|
||||||
|
slot: int
|
||||||
|
generation: int
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SlotGuard:
|
||||||
|
guard_id: str
|
||||||
|
slot: int
|
||||||
|
kind: str
|
||||||
|
state: str
|
||||||
|
created_at: float
|
||||||
|
review_after: float | None
|
||||||
|
cleared_at: float | None
|
||||||
|
cleared_by: str | None
|
||||||
|
clear_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlayerObservation:
|
||||||
|
availability: str
|
||||||
|
phase: str
|
||||||
|
active_slot: int | None
|
||||||
|
active_path: str | None
|
||||||
|
paused: bool | None
|
||||||
|
generation: int
|
||||||
|
reconciliation_state: str
|
||||||
|
reconciliation_due_at: float | None
|
||||||
|
reconciliation_id: str | None
|
||||||
|
last_status_at: float | None
|
||||||
|
last_telemetry_at: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouterObservation:
|
||||||
|
mode: str | None
|
||||||
|
freshness: str
|
||||||
|
observed_at: float | None
|
||||||
|
last_warning: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AudioLeaseRecord:
|
||||||
|
lease_id: str
|
||||||
|
operation_id: str | None
|
||||||
|
playback_generation: int | None
|
||||||
|
state: str
|
||||||
|
previous_mode: str | None
|
||||||
|
target_mode: str
|
||||||
|
restoration_mode: str | None
|
||||||
|
restoration_source: str
|
||||||
|
controller_changed: bool
|
||||||
|
confirmation_deadline: float | None
|
||||||
|
created_at: float
|
||||||
|
acquired_at: float | None
|
||||||
|
released_at: float | None
|
||||||
|
warning: str | None
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MigrationReport:
|
||||||
|
migrated: bool
|
||||||
|
source_path: str | None = None
|
||||||
|
source_sha256: str | None = None
|
||||||
|
backup_path: str | None = None
|
||||||
|
warnings: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class StoreError(RuntimeError):
|
||||||
|
"""Base persistence error."""
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationError(StoreError):
|
||||||
|
"""Legacy migration was rejected or could not complete safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class SlotProtectedError(StoreError):
|
||||||
|
"""A slot cannot be modified because live activity protects it."""
|
||||||
|
|
||||||
|
|
||||||
|
class StaleOperationError(StoreError):
|
||||||
|
"""An operation token no longer identifies the slot occupant."""
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
"""Resilient MQTT transport with explicit Player command guarantees."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from .config import ControllerConfig
|
||||||
|
from .contracts import (
|
||||||
|
PLAYER_COMMAND_QOS,
|
||||||
|
PLAYER_COMMAND_RETAIN,
|
||||||
|
PLAYER_STATE_QOS,
|
||||||
|
PLAYER_STATUS_QOS,
|
||||||
|
PLAYER_TELEMETRY_QOS,
|
||||||
|
ROUTER_COMMAND_QOS,
|
||||||
|
ROUTER_COMMAND_RETAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MqttEvent:
|
||||||
|
kind: str
|
||||||
|
topic: str | None = None
|
||||||
|
payload: bytes = b""
|
||||||
|
qos: int = 0
|
||||||
|
retain: bool = False
|
||||||
|
duplicate: bool = False
|
||||||
|
message_id: int | None = None
|
||||||
|
connection_generation: int | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
controller_echo: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PublishReceipt:
|
||||||
|
accepted: bool
|
||||||
|
message_id: int | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
uncertain: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MqttAdapter:
|
||||||
|
"""Own Paho lifecycle while keeping all domain work off its callbacks."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ControllerConfig,
|
||||||
|
*,
|
||||||
|
client: Any | None = None,
|
||||||
|
events: "queue.Queue[MqttEvent] | None" = None,
|
||||||
|
monotonic=time.monotonic,
|
||||||
|
):
|
||||||
|
self.config = config
|
||||||
|
self.events: "queue.Queue[MqttEvent]" = events or queue.Queue(
|
||||||
|
maxsize=config.mqtt.command_queue_size
|
||||||
|
)
|
||||||
|
self._injected_client = client is not None
|
||||||
|
self.client = client or self._new_client()
|
||||||
|
self._generation_lock = threading.Lock()
|
||||||
|
self._connection_generation = 0
|
||||||
|
self._connected = threading.Event()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._lifecycle_lock = threading.Lock()
|
||||||
|
self._started = False
|
||||||
|
self._loop_started = False
|
||||||
|
self._bootstrap_thread: threading.Thread | None = None
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._router_echo_lock = threading.Lock()
|
||||||
|
# token -> (exact payload, grace deadline). The deadline is ``None``
|
||||||
|
# while the correlated publish() call has not yet observed its
|
||||||
|
# PUBACK; such an entry never expires on its own, bounded instead by
|
||||||
|
# the publish ack timeout. Once PUBACK is observed, the entry keeps
|
||||||
|
# a short grace window so a subscription echo delivered just after
|
||||||
|
# the PUBACK is still recognized as ours rather than mistaken for
|
||||||
|
# operator intent.
|
||||||
|
self._pending_router_echoes: dict[str, tuple[bytes, float | None]] = {}
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self._configure_client()
|
||||||
|
|
||||||
|
def _new_client(self):
|
||||||
|
return mqtt.Client(
|
||||||
|
mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=self.config.mqtt.client_id,
|
||||||
|
clean_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _configure_client(self) -> None:
|
||||||
|
if self.config.mqtt.username is not None:
|
||||||
|
self.client.username_pw_set(
|
||||||
|
self.config.mqtt.username,
|
||||||
|
self.config.mqtt.password,
|
||||||
|
)
|
||||||
|
self.client.on_connect = self._on_connect
|
||||||
|
self.client.on_connect_fail = self._on_connect_fail
|
||||||
|
self.client.on_disconnect = self._on_disconnect
|
||||||
|
self.client.on_message = self._on_message
|
||||||
|
self.client.suppress_exceptions = True
|
||||||
|
self.client.manual_ack_set(True)
|
||||||
|
self.client.max_queued_messages_set(self.config.mqtt.command_queue_size)
|
||||||
|
self.client.reconnect_delay_set(
|
||||||
|
min_delay=self.config.mqtt.reconnect_min_seconds,
|
||||||
|
max_delay=self.config.mqtt.reconnect_max_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._connected.is_set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection_generation(self) -> int:
|
||||||
|
with self._generation_lock:
|
||||||
|
return self._connection_generation
|
||||||
|
|
||||||
|
@property
|
||||||
|
def started(self) -> bool:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
return self._started
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start without requiring the broker or network to be reachable."""
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
self._stop_event.clear()
|
||||||
|
try:
|
||||||
|
self._begin_async_connection()
|
||||||
|
except (OSError, mqtt.WebsocketConnectionError, RuntimeError) as exc:
|
||||||
|
self.last_error = f"initial MQTT setup failed: {exc}"
|
||||||
|
LOG.warning("%s; retrying in background", self.last_error)
|
||||||
|
self._start_bootstrap_retry()
|
||||||
|
|
||||||
|
def _begin_async_connection(self) -> None:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if not self._started or self._stop_event.is_set():
|
||||||
|
return
|
||||||
|
client = self.client
|
||||||
|
result = client.connect_async(
|
||||||
|
self.config.mqtt.host,
|
||||||
|
self.config.mqtt.port,
|
||||||
|
keepalive=self.config.mqtt.keepalive_seconds,
|
||||||
|
)
|
||||||
|
# Paho 2.x returns None after configuring an asynchronous connect;
|
||||||
|
# older/test clients may return MQTT_ERR_SUCCESS.
|
||||||
|
if result not in (None, mqtt.MQTT_ERR_SUCCESS):
|
||||||
|
raise RuntimeError(f"failed to configure MQTT connection: {result}")
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if (
|
||||||
|
not self._started
|
||||||
|
or self._stop_event.is_set()
|
||||||
|
or client is not self.client
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if not self._loop_started:
|
||||||
|
result = client.loop_start()
|
||||||
|
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
raise RuntimeError(f"failed to start MQTT network loop: {result}")
|
||||||
|
self._loop_started = True
|
||||||
|
|
||||||
|
def _start_bootstrap_retry(self) -> None:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._bootstrap_thread is not None and self._bootstrap_thread.is_alive():
|
||||||
|
return
|
||||||
|
self._bootstrap_thread = threading.Thread(
|
||||||
|
target=self._bootstrap_retry,
|
||||||
|
name="mqtt-bootstrap-retry",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._bootstrap_thread.start()
|
||||||
|
|
||||||
|
def _bootstrap_retry(self) -> None:
|
||||||
|
delay = float(self.config.mqtt.reconnect_min_seconds)
|
||||||
|
maximum = float(self.config.mqtt.reconnect_max_seconds)
|
||||||
|
while not self._stop_event.wait(delay):
|
||||||
|
try:
|
||||||
|
self._begin_async_connection()
|
||||||
|
self.last_error = None
|
||||||
|
return
|
||||||
|
except (OSError, mqtt.WebsocketConnectionError) as exc:
|
||||||
|
self.last_error = f"MQTT setup retry failed: {exc}"
|
||||||
|
LOG.warning("%s", self.last_error)
|
||||||
|
delay = min(maximum, max(delay * 2, 1.0))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
self.last_error = str(exc)
|
||||||
|
LOG.warning("%s", self.last_error)
|
||||||
|
delay = min(maximum, max(delay * 2, 1.0))
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
# Signal callbacks/retry work before waiting for the lifecycle lock.
|
||||||
|
# A callback already inside subscribe/connect then observes shutdown
|
||||||
|
# and cannot publish a post-stop connected state.
|
||||||
|
self._stop_event.set()
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if not self._started:
|
||||||
|
self._connected.clear()
|
||||||
|
return
|
||||||
|
self._started = False
|
||||||
|
loop_started = self._loop_started
|
||||||
|
self._loop_started = False
|
||||||
|
self._connected.clear()
|
||||||
|
if loop_started:
|
||||||
|
try:
|
||||||
|
self.client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("MQTT disconnect failed")
|
||||||
|
self.client.loop_stop()
|
||||||
|
thread = self._bootstrap_thread
|
||||||
|
if thread is not None and thread is not threading.current_thread():
|
||||||
|
thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reason_success(reason_code: Any) -> bool:
|
||||||
|
try:
|
||||||
|
return int(reason_code) == 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return reason_code == 0
|
||||||
|
|
||||||
|
def _on_connect(self, client, userdata, flags, reason_code, properties=None) -> None:
|
||||||
|
try:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if (
|
||||||
|
client is not self.client
|
||||||
|
or not self._started
|
||||||
|
or self._stop_event.is_set()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if not self._reason_success(reason_code):
|
||||||
|
self.last_error = f"MQTT connect failed: {reason_code}"
|
||||||
|
LOG.warning("%s", self.last_error)
|
||||||
|
return
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
return
|
||||||
|
with self._generation_lock:
|
||||||
|
self._connection_generation += 1
|
||||||
|
generation = self._connection_generation
|
||||||
|
self._connected.set()
|
||||||
|
self.last_error = None
|
||||||
|
subscriptions = (
|
||||||
|
(self.config.topics.player_status, PLAYER_STATUS_QOS),
|
||||||
|
(self.config.topics.player_telemetry, PLAYER_TELEMETRY_QOS),
|
||||||
|
(self.config.topics.player_state, PLAYER_STATE_QOS),
|
||||||
|
(self.config.topics.controller_command, 1),
|
||||||
|
(self.config.topics.router_mode_set, ROUTER_COMMAND_QOS),
|
||||||
|
(self.config.topics.router_mode_state, 1),
|
||||||
|
)
|
||||||
|
for topic, qos in subscriptions:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
self._connected.clear()
|
||||||
|
return
|
||||||
|
client.subscribe(topic, qos=qos)
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
self._connected.clear()
|
||||||
|
return
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
self._connected.clear()
|
||||||
|
return
|
||||||
|
self._enqueue(
|
||||||
|
MqttEvent(
|
||||||
|
kind="connected",
|
||||||
|
connection_generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("unexpected MQTT on_connect failure")
|
||||||
|
|
||||||
|
def _on_connect_fail(self, client, userdata) -> None:
|
||||||
|
self.last_error = (
|
||||||
|
f"MQTT connection to {self.config.mqtt.host}:{self.config.mqtt.port} failed"
|
||||||
|
)
|
||||||
|
LOG.warning("%s; Paho will retry", self.last_error)
|
||||||
|
|
||||||
|
def _on_disconnect(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
userdata,
|
||||||
|
disconnect_flags,
|
||||||
|
reason_code,
|
||||||
|
properties=None,
|
||||||
|
) -> None:
|
||||||
|
if client is not self.client:
|
||||||
|
return
|
||||||
|
self._connected.clear()
|
||||||
|
try:
|
||||||
|
self._enqueue(
|
||||||
|
MqttEvent(
|
||||||
|
kind="disconnected",
|
||||||
|
connection_generation=self.connection_generation,
|
||||||
|
reason=str(reason_code),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("unexpected MQTT on_disconnect failure")
|
||||||
|
|
||||||
|
def _on_message(self, client, userdata, message) -> None:
|
||||||
|
try:
|
||||||
|
# Capture client identity and generation atomically with session
|
||||||
|
# replacement. A callback that was already running when an
|
||||||
|
# uncertain command discards the transport must be able to leave a
|
||||||
|
# full queue so loop_stop() cannot deadlock waiting for it.
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if client is not self.client:
|
||||||
|
return
|
||||||
|
with self._generation_lock:
|
||||||
|
generation = self._connection_generation
|
||||||
|
topic = str(message.topic)
|
||||||
|
payload = bytes(message.payload)
|
||||||
|
retained = bool(getattr(message, "retain", False))
|
||||||
|
self._enqueue(
|
||||||
|
MqttEvent(
|
||||||
|
kind="message",
|
||||||
|
topic=topic,
|
||||||
|
payload=payload,
|
||||||
|
qos=int(getattr(message, "qos", 0)),
|
||||||
|
retain=retained,
|
||||||
|
duplicate=bool(getattr(message, "dup", False)),
|
||||||
|
message_id=getattr(message, "mid", None),
|
||||||
|
connection_generation=generation,
|
||||||
|
controller_echo=(
|
||||||
|
topic == self.config.topics.router_mode_set
|
||||||
|
and not retained
|
||||||
|
and self._claim_router_echo(payload)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("unexpected MQTT on_message failure")
|
||||||
|
|
||||||
|
def _enqueue(self, event: MqttEvent) -> bool:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if (
|
||||||
|
event.connection_generation is not None
|
||||||
|
and event.connection_generation != self.connection_generation
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.events.put(event, timeout=0.2)
|
||||||
|
return True
|
||||||
|
except queue.Full:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
def acknowledge(self, event: MqttEvent) -> bool:
|
||||||
|
if event.message_id is None or event.qos <= 0:
|
||||||
|
return True
|
||||||
|
if event.connection_generation != self.connection_generation:
|
||||||
|
LOG.warning(
|
||||||
|
"skipping stale MQTT ack mid=%s from generation=%s",
|
||||||
|
event.message_id,
|
||||||
|
event.connection_generation,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = self.client.ack(event.message_id, event.qos)
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("MQTT ack failed mid=%s", event.message_id)
|
||||||
|
return False
|
||||||
|
return result == mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
def _publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: str,
|
||||||
|
*,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
require_connection: bool,
|
||||||
|
invalidate_transport_on_uncertain: bool = False,
|
||||||
|
) -> PublishReceipt:
|
||||||
|
if require_connection and (not self.started or not self.connected):
|
||||||
|
return PublishReceipt(False, reason="MQTT is disconnected")
|
||||||
|
try:
|
||||||
|
info = self.client.publish(topic, payload, qos=qos, retain=retain)
|
||||||
|
except BaseException as exc:
|
||||||
|
LOG.exception("MQTT publish failed topic=%s", topic)
|
||||||
|
return PublishReceipt(False, reason=str(exc))
|
||||||
|
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
return PublishReceipt(False, message_id=getattr(info, "mid", None), reason=f"MQTT rc={info.rc}")
|
||||||
|
if qos > 0 and hasattr(info, "wait_for_publish"):
|
||||||
|
try:
|
||||||
|
info.wait_for_publish(
|
||||||
|
timeout=self.config.mqtt.publish_ack_timeout_seconds
|
||||||
|
)
|
||||||
|
acknowledged = bool(info.is_published())
|
||||||
|
except BaseException as exc:
|
||||||
|
acknowledged = False
|
||||||
|
detail = str(exc) or exc.__class__.__name__
|
||||||
|
else:
|
||||||
|
detail = (
|
||||||
|
f"no broker PUBACK within "
|
||||||
|
f"{self.config.mqtt.publish_ack_timeout_seconds:g} seconds"
|
||||||
|
)
|
||||||
|
if not acknowledged:
|
||||||
|
reason = f"MQTT QoS 1 publication is uncertain: {detail}"
|
||||||
|
if invalidate_transport_on_uncertain:
|
||||||
|
self._discard_unacknowledged_transport(reason)
|
||||||
|
return PublishReceipt(
|
||||||
|
False,
|
||||||
|
message_id=getattr(info, "mid", None),
|
||||||
|
reason=reason,
|
||||||
|
uncertain=True,
|
||||||
|
)
|
||||||
|
return PublishReceipt(True, message_id=getattr(info, "mid", None))
|
||||||
|
|
||||||
|
def _discard_unacknowledged_transport(self, reason: str) -> None:
|
||||||
|
"""Drop the Paho session so stale QoS 1 commands cannot replay."""
|
||||||
|
|
||||||
|
if self._injected_client:
|
||||||
|
# Test transports do not own a recreatable Paho session. Production
|
||||||
|
# always follows the replacement path below.
|
||||||
|
with self._generation_lock:
|
||||||
|
self._connection_generation += 1
|
||||||
|
self._connected.clear()
|
||||||
|
return
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
old_client = self.client
|
||||||
|
old_loop_started = self._loop_started
|
||||||
|
self._loop_started = False
|
||||||
|
should_restart = self._started and not self._stop_event.is_set()
|
||||||
|
self._connected.clear()
|
||||||
|
with self._generation_lock:
|
||||||
|
previous_generation = self._connection_generation
|
||||||
|
self._connection_generation += 1
|
||||||
|
self.client = self._new_client()
|
||||||
|
self._configure_client()
|
||||||
|
try:
|
||||||
|
old_client.disconnect()
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("failed to disconnect uncertain MQTT transport")
|
||||||
|
if old_loop_started:
|
||||||
|
try:
|
||||||
|
old_client.loop_stop()
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("failed to stop uncertain MQTT transport")
|
||||||
|
self.last_error = reason
|
||||||
|
try:
|
||||||
|
self.events.put_nowait(
|
||||||
|
MqttEvent(
|
||||||
|
kind="disconnected",
|
||||||
|
connection_generation=previous_generation,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except queue.Full:
|
||||||
|
# This method can run on the sole coordinator thread; blocking on
|
||||||
|
# its own queue would deadlock. The uncertain PublishReceipt still
|
||||||
|
# drives the operation-specific conservative transition.
|
||||||
|
LOG.error("MQTT event queue was full while discarding uncertain transport")
|
||||||
|
if should_restart:
|
||||||
|
self._start_bootstrap_retry()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_payload(payload: Mapping[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, allow_nan=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
def publish_player_command(self, payload: Mapping[str, Any]) -> PublishReceipt:
|
||||||
|
return self._publish(
|
||||||
|
self.config.topics.player_command,
|
||||||
|
self._json_payload(payload),
|
||||||
|
qos=PLAYER_COMMAND_QOS,
|
||||||
|
retain=PLAYER_COMMAND_RETAIN,
|
||||||
|
require_connection=True,
|
||||||
|
invalidate_transport_on_uncertain=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish_router_mode(self, mode: str) -> PublishReceipt:
|
||||||
|
# A broker normally sends our publication back through the matching
|
||||||
|
# subscription. Register the exact payload before publishing because
|
||||||
|
# Paho's network thread may receive that echo before publish()
|
||||||
|
# returns. A PUBACK only proves broker receipt of our publish, not
|
||||||
|
# that the broker has already forwarded (or even scheduled) our own
|
||||||
|
# subscription copy of it; that copy can legally arrive after the
|
||||||
|
# PUBACK. So the marker is not removed the instant publish()
|
||||||
|
# completes: it stays claimable for a short bounded grace window
|
||||||
|
# afterward, and is removed immediately once actually claimed or
|
||||||
|
# once the grace window elapses. This closes the specific race where
|
||||||
|
# the Controller's own late echo would otherwise be mistaken for
|
||||||
|
# live operator intent and cause automatic restoration ownership to
|
||||||
|
# be relinquished.
|
||||||
|
echo_token = uuid.uuid4().hex
|
||||||
|
payload = mode.encode("utf-8")
|
||||||
|
with self._router_echo_lock:
|
||||||
|
self._prune_router_echoes_locked()
|
||||||
|
self._pending_router_echoes[echo_token] = (payload, None)
|
||||||
|
receipt = self._publish(
|
||||||
|
self.config.topics.router_mode_set,
|
||||||
|
mode,
|
||||||
|
qos=ROUTER_COMMAND_QOS,
|
||||||
|
retain=ROUTER_COMMAND_RETAIN,
|
||||||
|
require_connection=True,
|
||||||
|
invalidate_transport_on_uncertain=True,
|
||||||
|
)
|
||||||
|
grace_deadline = (
|
||||||
|
self._monotonic() + self.config.mqtt.router_echo_grace_seconds
|
||||||
|
)
|
||||||
|
with self._router_echo_lock:
|
||||||
|
if echo_token in self._pending_router_echoes:
|
||||||
|
self._pending_router_echoes[echo_token] = (payload, grace_deadline)
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
def _claim_router_echo(self, payload: bytes) -> bool:
|
||||||
|
"""Claim one exact mode-set echo from an in-flight or recent local publish."""
|
||||||
|
|
||||||
|
with self._router_echo_lock:
|
||||||
|
self._prune_router_echoes_locked()
|
||||||
|
for token, (expected, _deadline) in tuple(
|
||||||
|
self._pending_router_echoes.items()
|
||||||
|
):
|
||||||
|
if expected == payload:
|
||||||
|
del self._pending_router_echoes[token]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _prune_router_echoes_locked(self) -> None:
|
||||||
|
"""Drop expired grace-window entries. Caller holds ``_router_echo_lock``."""
|
||||||
|
|
||||||
|
now = self._monotonic()
|
||||||
|
expired = [
|
||||||
|
token
|
||||||
|
for token, (_, deadline) in self._pending_router_echoes.items()
|
||||||
|
if deadline is not None and deadline <= now
|
||||||
|
]
|
||||||
|
for token in expired:
|
||||||
|
del self._pending_router_echoes[token]
|
||||||
|
|
||||||
|
def publish_legacy(self, topic: str, payload: str, *, retain: bool = True) -> PublishReceipt:
|
||||||
|
return self._publish(
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
qos=0,
|
||||||
|
retain=retain,
|
||||||
|
require_connection=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish_json(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
qos: int = 0,
|
||||||
|
retain: bool = False,
|
||||||
|
) -> PublishReceipt:
|
||||||
|
return self._publish(
|
||||||
|
topic,
|
||||||
|
self._json_payload(payload),
|
||||||
|
qos=qos,
|
||||||
|
retain=retain,
|
||||||
|
require_connection=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
"""Authoritative Player status/telemetry reduction and operation fencing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config import ControllerConfig
|
||||||
|
from .contracts import SLOT_IDS
|
||||||
|
from .models import PlayerObservation
|
||||||
|
from .store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_REASONS = frozenset(
|
||||||
|
{"stop", "finished", "mpv_error", "sigterm", "sigint", "shutdown"}
|
||||||
|
)
|
||||||
|
TERMINAL_ERROR_REASONS = frozenset({"startup_error", "worker_start_error"})
|
||||||
|
CONTROL_ACTIONS = frozenset({"play", "stop", "pause", "resume", "toggle"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlayerOperation:
|
||||||
|
operation_id: str
|
||||||
|
generation: int
|
||||||
|
action: str
|
||||||
|
slot: int | None
|
||||||
|
expected_path: str | None
|
||||||
|
deadline: float
|
||||||
|
reconciliation_id: str | None = None
|
||||||
|
state: str = "awaiting_player"
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlaybackEffect:
|
||||||
|
kind: str
|
||||||
|
slot: int | None = None
|
||||||
|
operation_id: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OperationInProgressError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackReducer:
|
||||||
|
"""Apply Player evidence without projecting Controller intent as success."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ControllerConfig,
|
||||||
|
store: ControllerStore,
|
||||||
|
*,
|
||||||
|
monotonic=time.monotonic,
|
||||||
|
wall_clock=time.time,
|
||||||
|
operation_timeout_seconds: float = 10.0,
|
||||||
|
):
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._wall_clock = wall_clock
|
||||||
|
self.operation_timeout_seconds = operation_timeout_seconds
|
||||||
|
self._operation_generation = 0
|
||||||
|
self.pending: PlayerOperation | None = None
|
||||||
|
self.last_operation: PlayerOperation | None = None
|
||||||
|
|
||||||
|
def begin_operation(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
slot: int | None = None,
|
||||||
|
expected_path: str | None = None,
|
||||||
|
operation_id: str | None = None,
|
||||||
|
) -> PlayerOperation:
|
||||||
|
if action not in CONTROL_ACTIONS:
|
||||||
|
raise ValueError(f"unsupported Player action {action!r}")
|
||||||
|
if isinstance(slot, bool) or (slot is not None and slot not in SLOT_IDS):
|
||||||
|
raise ValueError("slot must be in 1..10")
|
||||||
|
if self.pending is not None:
|
||||||
|
raise OperationInProgressError(
|
||||||
|
f"Player operation {self.pending.operation_id} is still pending"
|
||||||
|
)
|
||||||
|
self._operation_generation += 1
|
||||||
|
operation = PlayerOperation(
|
||||||
|
operation_id=operation_id or uuid.uuid4().hex,
|
||||||
|
generation=self._operation_generation,
|
||||||
|
action=action,
|
||||||
|
slot=slot,
|
||||||
|
expected_path=expected_path,
|
||||||
|
deadline=self._monotonic() + self.operation_timeout_seconds,
|
||||||
|
)
|
||||||
|
if action == "play" and slot is not None:
|
||||||
|
self.store.protect_slot(
|
||||||
|
slot,
|
||||||
|
"pending_play",
|
||||||
|
guard_id=f"operation-{operation.operation_id}",
|
||||||
|
details={"operation_id": operation.operation_id},
|
||||||
|
)
|
||||||
|
elif action == "play" and expected_path is not None:
|
||||||
|
due = None
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = (
|
||||||
|
self._wall_clock()
|
||||||
|
+ self.config.storage.uncertain_review_after_seconds
|
||||||
|
)
|
||||||
|
observation = self.store.mark_external_uncertain(
|
||||||
|
expected_path,
|
||||||
|
review_after=due,
|
||||||
|
details={
|
||||||
|
"operation_id": operation.operation_id,
|
||||||
|
"reason": "play command pending Player evidence",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
operation = replace(
|
||||||
|
operation,
|
||||||
|
reconciliation_id=observation.reconciliation_id,
|
||||||
|
)
|
||||||
|
self.pending = operation
|
||||||
|
return operation
|
||||||
|
|
||||||
|
def publish_failed(self, reason: str) -> PlaybackEffect | None:
|
||||||
|
if self.pending is None:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
self.pending.action == "play"
|
||||||
|
and self.pending.slot is None
|
||||||
|
and self.pending.reconciliation_id is not None
|
||||||
|
):
|
||||||
|
self.store.cancel_external_intent(
|
||||||
|
self.pending.reconciliation_id,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
return self._finish_operation("failed", reason)
|
||||||
|
|
||||||
|
def _finish_operation(self, state: str, message: str | None = None) -> PlaybackEffect:
|
||||||
|
assert self.pending is not None
|
||||||
|
operation = replace(self.pending, state=state, message=message)
|
||||||
|
self.pending = None
|
||||||
|
self.last_operation = operation
|
||||||
|
if operation.action == "play" and operation.slot is not None and state != "confirmed":
|
||||||
|
self.store.clear_playback_guards(
|
||||||
|
slot=operation.slot,
|
||||||
|
kind="pending_play",
|
||||||
|
reason=f"play operation {state}: {message or ''}".strip(),
|
||||||
|
)
|
||||||
|
return PlaybackEffect(
|
||||||
|
kind=f"operation_{state}",
|
||||||
|
slot=operation.slot,
|
||||||
|
operation_id=operation.operation_id,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slot(value: Any) -> int | None:
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
converted = int(value)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()):
|
||||||
|
return None
|
||||||
|
return converted if converted in SLOT_IDS else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _path(value: Any) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
def _matches_pending(self, slot: int | None, path: str | None) -> bool:
|
||||||
|
operation = self.pending
|
||||||
|
if operation is None:
|
||||||
|
return False
|
||||||
|
positive_match = False
|
||||||
|
if operation.slot is not None and slot is not None:
|
||||||
|
if operation.slot != slot:
|
||||||
|
return False
|
||||||
|
positive_match = True
|
||||||
|
if operation.expected_path is not None and path is not None:
|
||||||
|
if os.path.abspath(operation.expected_path) != os.path.abspath(path):
|
||||||
|
return False
|
||||||
|
positive_match = True
|
||||||
|
return positive_match
|
||||||
|
|
||||||
|
def _canonical_slot_for_path(self, path: str | None) -> int | None:
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
candidate = os.path.abspath(path)
|
||||||
|
for slot in SLOT_IDS:
|
||||||
|
slot_directory = os.path.abspath(
|
||||||
|
self.config.storage.media_base / f"slot{slot}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if os.path.commonpath((candidate, slot_directory)) == slot_directory:
|
||||||
|
return slot
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalized_identity(
|
||||||
|
self,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> tuple[int | None, str | None, str | None]:
|
||||||
|
slot = self._slot(payload.get("slot"))
|
||||||
|
path = self._path(payload.get("path"))
|
||||||
|
derived = self._canonical_slot_for_path(path)
|
||||||
|
if slot is not None and derived is not None and slot != derived:
|
||||||
|
return (
|
||||||
|
slot,
|
||||||
|
path,
|
||||||
|
f"Player identity conflicts: slot {slot} does not contain path {path!r}",
|
||||||
|
)
|
||||||
|
return (derived if slot is None else slot), path, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _same_playback_identity(
|
||||||
|
previous: PlayerObservation,
|
||||||
|
slot: int | None,
|
||||||
|
path: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if previous.active_slot != slot:
|
||||||
|
return False
|
||||||
|
if slot is None:
|
||||||
|
return (
|
||||||
|
previous.active_path is not None
|
||||||
|
and path is not None
|
||||||
|
and os.path.abspath(previous.active_path) == os.path.abspath(path)
|
||||||
|
)
|
||||||
|
if previous.active_path is None or path is None:
|
||||||
|
return True
|
||||||
|
return os.path.abspath(previous.active_path) == os.path.abspath(path)
|
||||||
|
|
||||||
|
def _active_evidence(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
slot: int | None,
|
||||||
|
path: str | None,
|
||||||
|
paused: bool | None,
|
||||||
|
status: Mapping[str, Any] | None = None,
|
||||||
|
telemetry: Mapping[str, Any] | None = None,
|
||||||
|
) -> PlayerObservation:
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
if slot is None and (path is None or path == previous.active_path):
|
||||||
|
slot = previous.active_slot
|
||||||
|
if path is None and (slot is None or slot == previous.active_slot):
|
||||||
|
path = previous.active_path
|
||||||
|
if paused is None and self._same_playback_identity(previous, slot, path):
|
||||||
|
paused = previous.paused
|
||||||
|
phase = "paused" if paused is True else "playing"
|
||||||
|
if slot is not None:
|
||||||
|
self.store.reconcile_active_guard(
|
||||||
|
slot,
|
||||||
|
kind="player_active",
|
||||||
|
details={"path": path},
|
||||||
|
)
|
||||||
|
elif path is not None:
|
||||||
|
self.store.reconcile_external_active()
|
||||||
|
return self.store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase=phase,
|
||||||
|
active_slot=slot,
|
||||||
|
active_path=path,
|
||||||
|
paused=paused,
|
||||||
|
reconciliation_state="confirmed",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
status_payload=status,
|
||||||
|
telemetry_payload=telemetry,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _idle_evidence(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: Mapping[str, Any] | None = None,
|
||||||
|
telemetry: Mapping[str, Any] | None = None,
|
||||||
|
) -> PlayerObservation:
|
||||||
|
# Persist idle before removing protection. A crash between the two
|
||||||
|
# steps can leave harmless over-protection, never unsafe under-protection.
|
||||||
|
observation = self.store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="idle",
|
||||||
|
active_slot=None,
|
||||||
|
active_path=None,
|
||||||
|
paused=False,
|
||||||
|
reconciliation_state="clear",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
status_payload=status,
|
||||||
|
telemetry_payload=telemetry,
|
||||||
|
)
|
||||||
|
self.store.clear_playback_guards(reason="fresh Player idle evidence")
|
||||||
|
return observation
|
||||||
|
|
||||||
|
def handle_status(self, payload: Any) -> list[PlaybackEffect]:
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return [PlaybackEffect("warning", message="Player status must be an object")]
|
||||||
|
state = payload.get("state")
|
||||||
|
if not isinstance(state, str) or not state:
|
||||||
|
return [PlaybackEffect("warning", message="Player status is missing state")]
|
||||||
|
state = state.strip()
|
||||||
|
slot, path, identity_warning = self._normalized_identity(payload)
|
||||||
|
if identity_warning is not None:
|
||||||
|
return [PlaybackEffect("warning", slot=slot, message=identity_warning)]
|
||||||
|
effects: list[PlaybackEffect] = []
|
||||||
|
|
||||||
|
if state == "offline":
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
due = None
|
||||||
|
active_known = (
|
||||||
|
previous.active_slot is not None or previous.active_path is not None
|
||||||
|
)
|
||||||
|
if previous.active_slot is not None:
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
guard = self.store.mark_slot_uncertain(
|
||||||
|
previous.active_slot,
|
||||||
|
review_after=due,
|
||||||
|
details={"reason": "Player offline"},
|
||||||
|
)
|
||||||
|
due = guard.review_after
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="offline",
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=previous.active_slot,
|
||||||
|
active_path=previous.active_path,
|
||||||
|
paused=previous.paused,
|
||||||
|
reconciliation_state=(
|
||||||
|
"operator_review"
|
||||||
|
if guard.state == "review_required"
|
||||||
|
else "awaiting_evidence"
|
||||||
|
),
|
||||||
|
reconciliation_due_at=due,
|
||||||
|
reconciliation_id=guard.guard_id,
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
elif previous.active_path is not None:
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
self.store.mark_external_uncertain(
|
||||||
|
previous.active_path,
|
||||||
|
review_after=due,
|
||||||
|
availability="offline",
|
||||||
|
details={"reason": "Player offline"},
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="offline",
|
||||||
|
phase=previous.phase,
|
||||||
|
active_slot=None,
|
||||||
|
active_path=None,
|
||||||
|
paused=previous.paused,
|
||||||
|
reconciliation_state="unknown",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
effects.append(
|
||||||
|
PlaybackEffect(
|
||||||
|
"player_offline",
|
||||||
|
slot=previous.active_slot,
|
||||||
|
message="Player availability is offline; playback is unknown",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
||||||
|
if state == "idle" and payload.get("message") == "player online":
|
||||||
|
if path is not None:
|
||||||
|
self._active_evidence(slot=slot, path=path, paused=None, status=payload)
|
||||||
|
effects.append(
|
||||||
|
PlaybackEffect(
|
||||||
|
"player_reconnected_active",
|
||||||
|
slot=slot,
|
||||||
|
message="Reconnect idle announcement carried an active path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.pending is not None and self.pending.action == "play":
|
||||||
|
if self._matches_pending(slot, path):
|
||||||
|
effects.append(
|
||||||
|
self._finish_operation(
|
||||||
|
"confirmed", "matching reconnect evidence"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effects.append(
|
||||||
|
self._finish_operation(
|
||||||
|
"failed",
|
||||||
|
"Player reconnected with different active playback",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._idle_evidence(status=payload)
|
||||||
|
effects.append(PlaybackEffect("player_idle"))
|
||||||
|
if self.pending is not None:
|
||||||
|
if self.pending.action == "stop":
|
||||||
|
effects.append(self._finish_operation("confirmed"))
|
||||||
|
elif self.pending.action == "play":
|
||||||
|
effects.append(
|
||||||
|
self._finish_operation(
|
||||||
|
"failed", "Player reconnected without active playback"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
||||||
|
if state == "idle":
|
||||||
|
reason = payload.get("reason")
|
||||||
|
if reason not in TERMINAL_REASONS:
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase=previous.phase,
|
||||||
|
active_slot=previous.active_slot,
|
||||||
|
active_path=previous.active_path,
|
||||||
|
paused=previous.paused,
|
||||||
|
reconciliation_state=previous.reconciliation_state,
|
||||||
|
reconciliation_due_at=previous.reconciliation_due_at,
|
||||||
|
reconciliation_id=previous.reconciliation_id,
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
PlaybackEffect(
|
||||||
|
"warning",
|
||||||
|
slot=previous.active_slot,
|
||||||
|
message="ambiguous Player idle status was not treated as terminal",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self._idle_evidence(status=payload)
|
||||||
|
effects.append(PlaybackEffect("player_idle", slot=slot, message=str(reason or "idle")))
|
||||||
|
if self.pending is not None:
|
||||||
|
if self.pending.action == "stop":
|
||||||
|
effects.append(self._finish_operation("confirmed", str(reason or "idle")))
|
||||||
|
elif self.pending.action == "play":
|
||||||
|
effects.append(self._finish_operation("failed", str(reason or "Player remained idle")))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
if state == "playing":
|
||||||
|
self._active_evidence(
|
||||||
|
slot=slot,
|
||||||
|
path=path,
|
||||||
|
paused=None,
|
||||||
|
status=payload,
|
||||||
|
)
|
||||||
|
effects.append(PlaybackEffect("player_active", slot=slot))
|
||||||
|
if (
|
||||||
|
self.pending is not None
|
||||||
|
and self.pending.action == "play"
|
||||||
|
):
|
||||||
|
if self._matches_pending(slot, path):
|
||||||
|
effects.append(self._finish_operation("confirmed"))
|
||||||
|
else:
|
||||||
|
effects.append(
|
||||||
|
self._finish_operation(
|
||||||
|
"failed",
|
||||||
|
"Player became active with a different slot or path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
||||||
|
if state in {"paused", "toggled"}:
|
||||||
|
self._active_evidence(
|
||||||
|
slot=slot,
|
||||||
|
path=path,
|
||||||
|
paused=None,
|
||||||
|
status=payload,
|
||||||
|
)
|
||||||
|
effects.append(
|
||||||
|
PlaybackEffect(
|
||||||
|
"control_issued",
|
||||||
|
slot=slot,
|
||||||
|
message=f"Player issued {state}; awaiting telemetry",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.pending is not None and self.pending.action == "play":
|
||||||
|
if self._matches_pending(slot, path):
|
||||||
|
effects.append(self._finish_operation("confirmed", state))
|
||||||
|
else:
|
||||||
|
effects.append(
|
||||||
|
self._finish_operation(
|
||||||
|
"failed",
|
||||||
|
"Player control status identified different playback",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
||||||
|
if state == "busy":
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
if (
|
||||||
|
slot is not None
|
||||||
|
or path is not None
|
||||||
|
or previous.active_slot is not None
|
||||||
|
or previous.active_path is not None
|
||||||
|
):
|
||||||
|
self._active_evidence(
|
||||||
|
slot=slot,
|
||||||
|
path=path,
|
||||||
|
paused=None,
|
||||||
|
status=payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=None,
|
||||||
|
active_path=None,
|
||||||
|
paused=None,
|
||||||
|
reconciliation_state="unknown",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
effects.append(PlaybackEffect("player_busy", slot=slot, message=payload.get("message")))
|
||||||
|
if self.pending is not None and self.pending.action == "play":
|
||||||
|
effects.append(self._finish_operation("failed", "Player is busy"))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
if state == "error":
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
message = str(payload.get("message") or "Player error")
|
||||||
|
reason = payload.get("reason")
|
||||||
|
if reason in TERMINAL_ERROR_REASONS:
|
||||||
|
terminal_slot = slot if slot is not None else previous.active_slot
|
||||||
|
self._idle_evidence(status=payload)
|
||||||
|
effects.extend(
|
||||||
|
(
|
||||||
|
PlaybackEffect(
|
||||||
|
"player_error",
|
||||||
|
slot=terminal_slot,
|
||||||
|
message=message,
|
||||||
|
),
|
||||||
|
PlaybackEffect(
|
||||||
|
"player_idle",
|
||||||
|
slot=terminal_slot,
|
||||||
|
message=str(reason),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.pending is not None:
|
||||||
|
effects.append(self._finish_operation("failed", message))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
active_slot = slot if slot is not None else previous.active_slot
|
||||||
|
active_path = path if path is not None else previous.active_path
|
||||||
|
due = None
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = (
|
||||||
|
self._wall_clock()
|
||||||
|
+ self.config.storage.uncertain_review_after_seconds
|
||||||
|
)
|
||||||
|
if active_slot is not None:
|
||||||
|
guard = self.store.mark_slot_uncertain(
|
||||||
|
active_slot,
|
||||||
|
review_after=due,
|
||||||
|
details={"reason": "Player error", "message": message},
|
||||||
|
)
|
||||||
|
due = guard.review_after
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="error",
|
||||||
|
active_slot=active_slot,
|
||||||
|
active_path=active_path,
|
||||||
|
paused=(
|
||||||
|
previous.paused
|
||||||
|
if self._same_playback_identity(
|
||||||
|
previous,
|
||||||
|
active_slot,
|
||||||
|
active_path,
|
||||||
|
)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
reconciliation_state=(
|
||||||
|
"operator_review"
|
||||||
|
if guard.state == "review_required"
|
||||||
|
else "awaiting_evidence"
|
||||||
|
),
|
||||||
|
reconciliation_due_at=due,
|
||||||
|
reconciliation_id=guard.guard_id,
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
elif active_path is not None:
|
||||||
|
self.store.mark_external_uncertain(
|
||||||
|
active_path,
|
||||||
|
review_after=due,
|
||||||
|
availability="online",
|
||||||
|
details={"reason": "Player error", "message": message},
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=None,
|
||||||
|
active_path=None,
|
||||||
|
paused=None,
|
||||||
|
reconciliation_state="unknown",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
status_payload=payload,
|
||||||
|
)
|
||||||
|
effects.append(PlaybackEffect("player_error", slot=active_slot, message=message))
|
||||||
|
if self.pending is not None:
|
||||||
|
effects.append(self._finish_operation("failed", message))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
effects.append(PlaybackEffect("warning", slot=slot, message=f"unknown Player state {state!r}"))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
def handle_telemetry(self, payload: Any) -> list[PlaybackEffect]:
|
||||||
|
if not isinstance(payload, Mapping) or payload.get("state") != "telemetry":
|
||||||
|
return [PlaybackEffect("warning", message="invalid Player telemetry envelope")]
|
||||||
|
slot, path, identity_warning = self._normalized_identity(payload)
|
||||||
|
if identity_warning is not None:
|
||||||
|
return [PlaybackEffect("warning", slot=slot, message=identity_warning)]
|
||||||
|
effects: list[PlaybackEffect] = []
|
||||||
|
if path is None:
|
||||||
|
self._idle_evidence(telemetry=payload)
|
||||||
|
effects.append(PlaybackEffect("player_idle", slot=slot, message="cleared telemetry"))
|
||||||
|
if self.pending is not None:
|
||||||
|
if self.pending.action == "stop":
|
||||||
|
effects.append(self._finish_operation("confirmed", "cleared telemetry"))
|
||||||
|
elif self.pending.action == "play":
|
||||||
|
effects.append(self._finish_operation("failed", "playback cleared"))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
paused_value = payload.get("paused")
|
||||||
|
paused = paused_value if isinstance(paused_value, bool) else None
|
||||||
|
observation = self._active_evidence(
|
||||||
|
slot=slot,
|
||||||
|
path=path,
|
||||||
|
paused=paused,
|
||||||
|
telemetry=payload,
|
||||||
|
)
|
||||||
|
effects.append(PlaybackEffect("player_telemetry", slot=observation.active_slot))
|
||||||
|
operation = self.pending
|
||||||
|
if operation is None:
|
||||||
|
return effects
|
||||||
|
if not self._matches_pending(slot, path):
|
||||||
|
effects.append(
|
||||||
|
self._finish_operation(
|
||||||
|
"failed",
|
||||||
|
"Player telemetry identified a different slot or path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
if operation.action == "play":
|
||||||
|
effects.append(self._finish_operation("confirmed", "matching telemetry"))
|
||||||
|
elif operation.action == "pause" and paused is True:
|
||||||
|
effects.append(self._finish_operation("confirmed", "telemetry paused=true"))
|
||||||
|
elif operation.action == "resume" and paused is False:
|
||||||
|
effects.append(self._finish_operation("confirmed", "telemetry paused=false"))
|
||||||
|
elif operation.action == "toggle" and paused is not None:
|
||||||
|
effects.append(self._finish_operation("confirmed", f"telemetry paused={paused}"))
|
||||||
|
return effects
|
||||||
|
|
||||||
|
def check_timeout(self) -> PlaybackEffect | None:
|
||||||
|
operation = self.pending
|
||||||
|
if operation is None or self._monotonic() < operation.deadline:
|
||||||
|
return None
|
||||||
|
if operation.action == "play":
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
has_confirmed_active = (
|
||||||
|
previous.reconciliation_state == "confirmed"
|
||||||
|
and (previous.active_slot is not None or previous.active_path is not None)
|
||||||
|
)
|
||||||
|
if has_confirmed_active and self._matches_pending(
|
||||||
|
previous.active_slot,
|
||||||
|
previous.active_path,
|
||||||
|
):
|
||||||
|
return self._finish_operation(
|
||||||
|
"confirmed", "matching authoritative Player evidence"
|
||||||
|
)
|
||||||
|
if not has_confirmed_active:
|
||||||
|
self._mark_play_uncertain(operation, "play timeout")
|
||||||
|
elif operation.action == "stop":
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
if previous.active_slot is not None:
|
||||||
|
due = None
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
guard = self.store.mark_slot_uncertain(
|
||||||
|
previous.active_slot,
|
||||||
|
review_after=due,
|
||||||
|
details={"operation_id": operation.operation_id, "reason": "stop timeout"},
|
||||||
|
)
|
||||||
|
due = guard.review_after
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="unknown",
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=previous.active_slot,
|
||||||
|
active_path=previous.active_path,
|
||||||
|
paused=previous.paused,
|
||||||
|
reconciliation_state=(
|
||||||
|
"operator_review"
|
||||||
|
if guard.state == "review_required"
|
||||||
|
else "awaiting_evidence"
|
||||||
|
),
|
||||||
|
reconciliation_due_at=due,
|
||||||
|
reconciliation_id=guard.guard_id,
|
||||||
|
)
|
||||||
|
elif previous.active_path is not None:
|
||||||
|
due = None
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
self.store.mark_external_uncertain(
|
||||||
|
previous.active_path,
|
||||||
|
review_after=due,
|
||||||
|
details={
|
||||||
|
"operation_id": operation.operation_id,
|
||||||
|
"reason": "stop timeout",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif operation.action in {"pause", "resume", "toggle"}:
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
if previous.active_slot is not None or previous.active_path is not None:
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability=previous.availability,
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=previous.active_slot,
|
||||||
|
active_path=previous.active_path,
|
||||||
|
paused=None,
|
||||||
|
reconciliation_state=previous.reconciliation_state,
|
||||||
|
reconciliation_due_at=previous.reconciliation_due_at,
|
||||||
|
reconciliation_id=previous.reconciliation_id,
|
||||||
|
)
|
||||||
|
return self._finish_operation("timed_out", "no authoritative Player confirmation")
|
||||||
|
|
||||||
|
def _mark_play_uncertain(self, operation: PlayerOperation, reason: str) -> None:
|
||||||
|
due = None
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
slot = operation.slot or self._canonical_slot_for_path(operation.expected_path)
|
||||||
|
if slot is not None:
|
||||||
|
guard = self.store.mark_slot_uncertain(
|
||||||
|
slot,
|
||||||
|
review_after=due,
|
||||||
|
details={"operation_id": operation.operation_id, "reason": reason},
|
||||||
|
)
|
||||||
|
due = guard.review_after
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="unknown",
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=slot,
|
||||||
|
active_path=operation.expected_path,
|
||||||
|
paused=None,
|
||||||
|
reconciliation_state=(
|
||||||
|
"operator_review"
|
||||||
|
if guard.state == "review_required"
|
||||||
|
else "awaiting_evidence"
|
||||||
|
),
|
||||||
|
reconciliation_due_at=due,
|
||||||
|
reconciliation_id=guard.guard_id,
|
||||||
|
)
|
||||||
|
elif operation.expected_path is not None:
|
||||||
|
self.store.mark_external_uncertain(
|
||||||
|
operation.expected_path,
|
||||||
|
review_after=due,
|
||||||
|
details={"operation_id": operation.operation_id, "reason": reason},
|
||||||
|
)
|
||||||
|
|
||||||
|
def publication_uncertain(self, reason: str) -> PlaybackEffect | None:
|
||||||
|
if self.pending is None:
|
||||||
|
return None
|
||||||
|
operation = self.pending
|
||||||
|
if operation.action == "play":
|
||||||
|
self._mark_play_uncertain(operation, reason)
|
||||||
|
return self._finish_operation("uncertain", reason)
|
||||||
|
|
||||||
|
def mark_transport_uncertain(self, reason: str) -> PlaybackEffect:
|
||||||
|
previous = self.store.player_observation()
|
||||||
|
pending_play = (
|
||||||
|
self.pending
|
||||||
|
if self.pending is not None and self.pending.action == "play"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
active_slot = previous.active_slot
|
||||||
|
active_path = previous.active_path
|
||||||
|
if active_slot is None and active_path is None and pending_play is not None:
|
||||||
|
active_slot = pending_play.slot or self._canonical_slot_for_path(
|
||||||
|
pending_play.expected_path
|
||||||
|
)
|
||||||
|
active_path = pending_play.expected_path
|
||||||
|
due = None
|
||||||
|
if active_slot is not None:
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
guard = self.store.mark_slot_uncertain(
|
||||||
|
active_slot,
|
||||||
|
review_after=due,
|
||||||
|
details={"reason": reason},
|
||||||
|
)
|
||||||
|
due = guard.review_after
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="unknown",
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=active_slot,
|
||||||
|
active_path=active_path,
|
||||||
|
paused=previous.paused,
|
||||||
|
reconciliation_state=(
|
||||||
|
"operator_review"
|
||||||
|
if guard.state == "review_required"
|
||||||
|
else "awaiting_evidence"
|
||||||
|
),
|
||||||
|
reconciliation_due_at=due,
|
||||||
|
reconciliation_id=guard.guard_id,
|
||||||
|
)
|
||||||
|
elif active_path is not None:
|
||||||
|
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||||
|
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||||
|
self.store.mark_external_uncertain(
|
||||||
|
active_path,
|
||||||
|
review_after=due,
|
||||||
|
details={"reason": reason},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability="unknown",
|
||||||
|
phase=previous.phase,
|
||||||
|
active_slot=None,
|
||||||
|
active_path=None,
|
||||||
|
paused=previous.paused,
|
||||||
|
reconciliation_state="unknown",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
)
|
||||||
|
return PlaybackEffect(
|
||||||
|
"transport_uncertain",
|
||||||
|
slot=active_slot,
|
||||||
|
message=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def projected_slot_state(self, slot: int) -> str:
|
||||||
|
record = self.store.get_slot(slot)
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
if observation.active_slot == slot:
|
||||||
|
if observation.reconciliation_state == "operator_review":
|
||||||
|
return "operator_review"
|
||||||
|
if observation.phase == "paused" and observation.paused is True:
|
||||||
|
return "paused"
|
||||||
|
if observation.phase == "playing":
|
||||||
|
return "playing"
|
||||||
|
if observation.phase in {"unknown", "error"}:
|
||||||
|
return "unknown"
|
||||||
|
guard = next(
|
||||||
|
(guard for guard in self.store.list_live_guards() if guard.slot == slot),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if guard is not None:
|
||||||
|
if guard.kind == "pending_play":
|
||||||
|
return record.media_state
|
||||||
|
return "operator_review" if guard.state == "review_required" else "unknown"
|
||||||
|
return record.media_state
|
||||||
|
|
||||||
|
def mark_due_reconciliation(self) -> list[PlaybackEffect]:
|
||||||
|
now = self._wall_clock()
|
||||||
|
due = self.store.mark_due_guards_for_review(now)
|
||||||
|
external_due = self.store.mark_external_reconciliation_due(now)
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
if observation.active_slot is not None and any(
|
||||||
|
guard.slot == observation.active_slot for guard in due
|
||||||
|
):
|
||||||
|
self.store.set_player_observation(
|
||||||
|
availability=observation.availability,
|
||||||
|
phase="unknown",
|
||||||
|
active_slot=observation.active_slot,
|
||||||
|
active_path=observation.active_path,
|
||||||
|
paused=observation.paused,
|
||||||
|
reconciliation_state="operator_review",
|
||||||
|
reconciliation_due_at=observation.reconciliation_due_at,
|
||||||
|
reconciliation_id=observation.reconciliation_id,
|
||||||
|
)
|
||||||
|
effects = [
|
||||||
|
PlaybackEffect(
|
||||||
|
"operator_review",
|
||||||
|
slot=guard.slot,
|
||||||
|
operation_id=guard.guard_id,
|
||||||
|
message=(
|
||||||
|
"Playback remains unknown and protected; fresh Player evidence "
|
||||||
|
"or an administrative clear is required "
|
||||||
|
f"(review_id={guard.guard_id})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for guard in due
|
||||||
|
]
|
||||||
|
if external_due is not None:
|
||||||
|
review_id, path = external_due
|
||||||
|
effects.append(
|
||||||
|
PlaybackEffect(
|
||||||
|
"operator_review",
|
||||||
|
operation_id=review_id,
|
||||||
|
message=(
|
||||||
|
f"Playback at {path!r} remains unknown and protected; "
|
||||||
|
"fresh Player evidence or an administrative clear is required "
|
||||||
|
f"(review_id={review_id})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return effects
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""Timeout-bounded Audio Router acquisition and restoration leases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .config import ControllerConfig
|
||||||
|
from .models import AudioLeaseRecord
|
||||||
|
from .mqtt_adapter import PublishReceipt
|
||||||
|
from .store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
ModePublisher = Callable[[str], PublishReceipt]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouterDecision:
|
||||||
|
action: str
|
||||||
|
lease_id: str | None = None
|
||||||
|
warning: str | None = None
|
||||||
|
continued: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AudioRouterCoordinator:
|
||||||
|
"""Own one conservative Router lease for one playback session."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ControllerConfig,
|
||||||
|
store: ControllerStore,
|
||||||
|
publish_mode: ModePublisher,
|
||||||
|
*,
|
||||||
|
clock=time.monotonic,
|
||||||
|
):
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self.publish_mode = publish_mode
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
def observe_mode(self, mode: str) -> RouterDecision:
|
||||||
|
mode = mode.strip()
|
||||||
|
if not mode:
|
||||||
|
warning = "Audio Router published an empty mode"
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("warning", warning=warning)
|
||||||
|
self.store.set_router_observation(mode=mode, freshness="current")
|
||||||
|
lease = self.store.live_audio_lease()
|
||||||
|
if lease is None:
|
||||||
|
return RouterDecision("observed")
|
||||||
|
|
||||||
|
if lease.state == "acquiring":
|
||||||
|
if mode == lease.target_mode:
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease.lease_id,
|
||||||
|
state="held",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
acquired=True,
|
||||||
|
)
|
||||||
|
return RouterDecision("ready", lease_id=lease.lease_id)
|
||||||
|
# State is evidence of the Router's current output, not evidence
|
||||||
|
# that an operator issued a command. It may simply be a periodic
|
||||||
|
# repeat of the pre-acquisition state, so keep waiting for the
|
||||||
|
# finite confirmation deadline.
|
||||||
|
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||||
|
|
||||||
|
if lease.state == "held":
|
||||||
|
if mode != lease.target_mode:
|
||||||
|
warning = (
|
||||||
|
f"Audio Router changed from Controller-owned {lease.target_mode!r} "
|
||||||
|
f"to {mode!r}; automatic restoration was relinquished"
|
||||||
|
)
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease.lease_id,
|
||||||
|
state="ownership_lost",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("ownership_lost", lease.lease_id, warning)
|
||||||
|
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||||
|
|
||||||
|
if lease.state == "unconfirmed":
|
||||||
|
# A late state event must never resurrect a lease that already
|
||||||
|
# timed out or deliberately continued without confirmation.
|
||||||
|
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||||
|
|
||||||
|
if lease.state == "restore_pending":
|
||||||
|
if mode == lease.restoration_mode:
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease.lease_id,
|
||||||
|
state="released",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
return RouterDecision("restored", lease_id=lease.lease_id)
|
||||||
|
# The Router may repeat its current state before applying the
|
||||||
|
# restoration command. Wait for the bounded deadline; explicit
|
||||||
|
# mode-set intent is handled separately below.
|
||||||
|
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||||
|
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||||
|
|
||||||
|
def observe_mode_command(
|
||||||
|
self,
|
||||||
|
mode: str,
|
||||||
|
*,
|
||||||
|
retained: bool = False,
|
||||||
|
controller_echo: bool = False,
|
||||||
|
) -> RouterDecision:
|
||||||
|
"""Observe a Router mode-set publication without treating state as intent.
|
||||||
|
|
||||||
|
The MQTT adapter marks an exact echo received while a Controller QoS 1
|
||||||
|
publication is in flight. Every other live mode-set publication is
|
||||||
|
conservatively treated as operator intent. Retained commands are old
|
||||||
|
broker state and can never establish current operator intent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode = mode.strip()
|
||||||
|
if not mode:
|
||||||
|
warning = "Audio Router published an empty mode command"
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("warning", warning=warning)
|
||||||
|
|
||||||
|
lease = self.store.live_audio_lease()
|
||||||
|
lease_id = None if lease is None else lease.lease_id
|
||||||
|
if retained:
|
||||||
|
warning = (
|
||||||
|
f"Retained Audio Router mode command {mode!r} was ignored as stale intent"
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("retained_ignored", lease_id, warning)
|
||||||
|
if controller_echo:
|
||||||
|
return RouterDecision("self_echo", lease_id=lease_id)
|
||||||
|
if lease is None:
|
||||||
|
return RouterDecision("operator_intent")
|
||||||
|
|
||||||
|
if lease.state == "acquiring":
|
||||||
|
return self._acquisition_failed(
|
||||||
|
lease.lease_id,
|
||||||
|
(
|
||||||
|
f"Audio Router received an operator mode command for {mode!r} "
|
||||||
|
f"while {lease.target_mode!r} confirmation was pending; "
|
||||||
|
"automatic restoration ownership was relinquished"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if lease.state in {"held", "unconfirmed", "restore_pending"}:
|
||||||
|
warning = (
|
||||||
|
f"Audio Router received an operator mode command for {mode!r}; "
|
||||||
|
"automatic restoration was relinquished"
|
||||||
|
)
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease.lease_id,
|
||||||
|
state="ownership_lost",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("ownership_lost", lease.lease_id, warning)
|
||||||
|
return RouterDecision("operator_intent", lease_id=lease.lease_id)
|
||||||
|
|
||||||
|
def acquire(self, operation_id: str) -> RouterDecision:
|
||||||
|
observation = self.store.router_observation()
|
||||||
|
known = observation.freshness == "current" and observation.mode is not None
|
||||||
|
target = self.config.router.required_mode
|
||||||
|
previous = observation.mode if known else None
|
||||||
|
lease_id = uuid.uuid4().hex
|
||||||
|
|
||||||
|
if known and previous == target:
|
||||||
|
self.store.create_audio_lease(
|
||||||
|
lease_id=lease_id,
|
||||||
|
operation_id=operation_id,
|
||||||
|
state="held",
|
||||||
|
previous_mode=previous,
|
||||||
|
target_mode=target,
|
||||||
|
restoration_mode=None,
|
||||||
|
restoration_source="none",
|
||||||
|
controller_changed=False,
|
||||||
|
confirmation_deadline=None,
|
||||||
|
)
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="held",
|
||||||
|
acquired=True,
|
||||||
|
)
|
||||||
|
return RouterDecision("ready", lease_id=lease_id)
|
||||||
|
|
||||||
|
if known:
|
||||||
|
restoration_mode = previous
|
||||||
|
restoration_source = "observed_previous"
|
||||||
|
elif self.config.router.fallback_restore_mode is not None:
|
||||||
|
restoration_mode = self.config.router.fallback_restore_mode
|
||||||
|
restoration_source = "configured_fallback"
|
||||||
|
else:
|
||||||
|
restoration_mode = None
|
||||||
|
restoration_source = "none"
|
||||||
|
|
||||||
|
policy = self.config.router.when_state_unknown
|
||||||
|
if not known and policy == "reject":
|
||||||
|
warning = "Audio Router state is unavailable; playback was rejected by policy"
|
||||||
|
self.store.create_audio_lease(
|
||||||
|
lease_id=lease_id,
|
||||||
|
operation_id=operation_id,
|
||||||
|
state="abandoned",
|
||||||
|
previous_mode=None,
|
||||||
|
target_mode=target,
|
||||||
|
restoration_mode=restoration_mode,
|
||||||
|
restoration_source=restoration_source,
|
||||||
|
controller_changed=False,
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("rejected", lease_id, warning)
|
||||||
|
|
||||||
|
deadline = self._clock() + self.config.router.confirmation_timeout_seconds
|
||||||
|
initial_state = "unconfirmed" if not known and policy == "continue" else "acquiring"
|
||||||
|
self.store.create_audio_lease(
|
||||||
|
lease_id=lease_id,
|
||||||
|
operation_id=operation_id,
|
||||||
|
state=initial_state,
|
||||||
|
previous_mode=previous,
|
||||||
|
target_mode=target,
|
||||||
|
restoration_mode=restoration_mode,
|
||||||
|
restoration_source=restoration_source,
|
||||||
|
controller_changed=True,
|
||||||
|
confirmation_deadline=(deadline if initial_state == "acquiring" else None),
|
||||||
|
)
|
||||||
|
receipt = self.publish_mode(target)
|
||||||
|
if not receipt.accepted:
|
||||||
|
return self._acquisition_failed(
|
||||||
|
lease_id,
|
||||||
|
f"could not request Audio Router mode {target!r}: {receipt.reason}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if initial_state == "unconfirmed":
|
||||||
|
warning = (
|
||||||
|
f"Audio Router state is unavailable; requested {target!r} and "
|
||||||
|
"continued immediately without restoration ownership"
|
||||||
|
)
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="unconfirmed",
|
||||||
|
warning=warning,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("ready", lease_id, warning, continued=True)
|
||||||
|
return RouterDecision("waiting", lease_id=lease_id)
|
||||||
|
|
||||||
|
def check_deadline(self) -> RouterDecision | None:
|
||||||
|
lease = self.store.live_audio_lease()
|
||||||
|
if (
|
||||||
|
lease is None
|
||||||
|
or lease.confirmation_deadline is None
|
||||||
|
or self._clock() < lease.confirmation_deadline
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if lease.state == "acquiring":
|
||||||
|
return self._acquisition_failed(
|
||||||
|
lease.lease_id,
|
||||||
|
(
|
||||||
|
f"Audio Router did not confirm {lease.target_mode!r} within "
|
||||||
|
f"{self.config.router.confirmation_timeout_seconds:g} seconds"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if lease.state == "restore_pending":
|
||||||
|
warning = (
|
||||||
|
f"Audio Router did not confirm restoration to {lease.restoration_mode!r}; "
|
||||||
|
"no further automatic change will be attempted"
|
||||||
|
)
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease.lease_id,
|
||||||
|
state="abandoned",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("restore_failed", lease.lease_id, warning)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _acquisition_failed(self, lease_id: str, warning: str) -> RouterDecision:
|
||||||
|
continue_playback = self.config.router.continue_on_confirmation_failure
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="unconfirmed" if continue_playback else "abandoned",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=not continue_playback,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision(
|
||||||
|
"ready" if continue_playback else "rejected",
|
||||||
|
lease_id,
|
||||||
|
warning,
|
||||||
|
continued=continue_playback,
|
||||||
|
)
|
||||||
|
|
||||||
|
def release(self, lease_id: str | None) -> RouterDecision:
|
||||||
|
if lease_id is None:
|
||||||
|
return RouterDecision("released")
|
||||||
|
lease = self.store.get_audio_lease(lease_id)
|
||||||
|
if lease is None:
|
||||||
|
return RouterDecision("released")
|
||||||
|
if lease.state == "held" and not lease.controller_changed:
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="released",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
return RouterDecision("released", lease_id)
|
||||||
|
if lease.state != "held":
|
||||||
|
if lease.state in {"unconfirmed", "acquiring"}:
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="abandoned",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning="Audio Router was never confirmed; restoration was skipped",
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
return RouterDecision("released", lease_id)
|
||||||
|
|
||||||
|
observation = self.store.router_observation()
|
||||||
|
if observation.freshness != "current" or observation.mode != lease.target_mode:
|
||||||
|
warning = "Audio Router ownership is no longer current; restoration was skipped"
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="ownership_lost",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("ownership_lost", lease_id, warning)
|
||||||
|
if lease.restoration_mode is None:
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="released",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
return RouterDecision("released", lease_id)
|
||||||
|
|
||||||
|
receipt = self.publish_mode(lease.restoration_mode)
|
||||||
|
if not receipt.accepted:
|
||||||
|
warning = f"could not request Audio Router restoration: {receipt.reason}"
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="abandoned",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("restore_failed", lease_id, warning)
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="restore_pending",
|
||||||
|
confirmation_deadline=(
|
||||||
|
self._clock() + self.config.router.confirmation_timeout_seconds
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return RouterDecision("restore_waiting", lease_id)
|
||||||
|
|
||||||
|
def connection_lost(self) -> RouterDecision | None:
|
||||||
|
observation = self.store.router_observation()
|
||||||
|
self.store.set_router_observation(
|
||||||
|
mode=observation.mode,
|
||||||
|
freshness="stale",
|
||||||
|
warning="MQTT continuity was lost",
|
||||||
|
)
|
||||||
|
lease = self.store.live_audio_lease()
|
||||||
|
if lease is None:
|
||||||
|
return None
|
||||||
|
if lease.state == "acquiring":
|
||||||
|
return self._acquisition_failed(
|
||||||
|
lease.lease_id,
|
||||||
|
"MQTT disconnected while waiting for Audio Router confirmation",
|
||||||
|
)
|
||||||
|
warning = "MQTT continuity was lost; automatic Audio Router restoration was relinquished"
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease.lease_id,
|
||||||
|
state="ownership_lost",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=warning,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(warning)
|
||||||
|
return RouterDecision("ownership_lost", lease.lease_id, warning)
|
||||||
|
|
||||||
|
def abandon(self, lease_id: str | None, reason: str) -> None:
|
||||||
|
if lease_id is None:
|
||||||
|
return
|
||||||
|
lease = self.store.get_audio_lease(lease_id)
|
||||||
|
if lease is None or lease.state in {"released", "abandoned", "ownership_lost"}:
|
||||||
|
return
|
||||||
|
self.store.update_audio_lease(
|
||||||
|
lease_id,
|
||||||
|
state="abandoned",
|
||||||
|
confirmation_deadline=None,
|
||||||
|
warning=reason,
|
||||||
|
released=True,
|
||||||
|
)
|
||||||
|
self._record_warning(reason)
|
||||||
|
|
||||||
|
def _record_warning(self, warning: str) -> None:
|
||||||
|
observation = self.store.router_observation()
|
||||||
|
self.store.set_router_observation(
|
||||||
|
mode=observation.mode,
|
||||||
|
freshness=observation.freshness,
|
||||||
|
warning=warning,
|
||||||
|
)
|
||||||
|
self.store.record_audit_event(
|
||||||
|
"audio_router_warning",
|
||||||
|
actor="controller",
|
||||||
|
details={"message": warning},
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
+2249
File diff suppressed because it is too large
Load Diff
+63
-817
@@ -1,838 +1,84 @@
|
|||||||
import os
|
"""Deprecated, lazy compatibility facade for the Controller v2 service.
|
||||||
import time
|
|
||||||
import json
|
New deployments use :mod:`wsgi`. These functions remain for local callers
|
||||||
|
that imported the original module, but importing this module never opens the
|
||||||
|
database, creates directories, starts threads, or connects to MQTT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import subprocess
|
from typing import Any
|
||||||
from pathlib import Path
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
import yaml
|
from controller.config import ControllerConfig, load_config
|
||||||
import paho.mqtt.client as mqtt
|
from controller.service import ControllerService, ControllerUnavailableError
|
||||||
|
|
||||||
# --- Config / paths ---
|
|
||||||
|
|
||||||
MEDIA_BASE = Path(os.environ.get("YTPLAYER_MEDIA_BASE", "/mnt/Media/YTPlayer"))
|
_lifecycle_lock = threading.Lock()
|
||||||
DATA_BASE = Path(os.environ.get("YTPLAYER_DATA_BASE", "/controller-data"))
|
_service: ControllerService | None = None
|
||||||
STATE_PATH = DATA_BASE / "playlists.yaml"
|
_config: ControllerConfig | None = None
|
||||||
|
|
||||||
MAX_SLOTS = int(os.environ.get("YTPLAYER_MAX_SLOTS", "10"))
|
# Kept as transition indicators for integrations that inspected the old module.
|
||||||
RETENTION_SECONDS = int(os.environ.get("YTPLAYER_RETENTION_SECONDS", str(48 * 3600)))
|
mqtt_client: Any | None = None
|
||||||
|
mqtt_started = False
|
||||||
|
|
||||||
MQTT_HOST = os.environ.get("MQTT_HOST", "10.40.0.2")
|
|
||||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
|
||||||
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "yt-controller")
|
|
||||||
|
|
||||||
TOPIC_INGEST_PROGRESS = "youtube/ingest/progress"
|
def start_controller() -> None:
|
||||||
TOPIC_PLAYBACK_BASE = "youtube/media/playback" # e.g. youtube/media/playback/1/title
|
"""Construct and start the singleton compatibility service explicitly."""
|
||||||
TOPIC_PLAYER_STATUS = "youtube/player/status" # JSON from player_service
|
|
||||||
TOPIC_PLAYER_CMD = "youtube/player/cmd" # commands to player_service
|
|
||||||
TOPIC_CONTROLLER_CMD = "youtube/controller/cmd" # commands from HA / StreamDeck
|
|
||||||
|
|
||||||
MEDIA_BASE.mkdir(parents=True, exist_ok=True)
|
global _config, _service, mqtt_client, mqtt_started
|
||||||
DATA_BASE.mkdir(parents=True, exist_ok=True)
|
with _lifecycle_lock:
|
||||||
|
if _service is not None and _service.started:
|
||||||
# --- MQTT client ---
|
|
||||||
|
|
||||||
import logging, sys
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
|
||||||
|
|
||||||
def _on_connect(client, userdata, flags, rc, properties=None):
|
|
||||||
print(f"[mqtt] connected rc={rc}")
|
|
||||||
if rc == 0:
|
|
||||||
client.subscribe(TOPIC_PLAYER_STATUS, qos=1)
|
|
||||||
client.subscribe(TOPIC_CONTROLLER_CMD, qos=1)
|
|
||||||
print(f"[controller] subscribed to {TOPIC_PLAYER_STATUS} and {TOPIC_CONTROLLER_CMD}")
|
|
||||||
else:
|
|
||||||
print(f"[mqtt] connect failed rc={rc}")
|
|
||||||
|
|
||||||
def _on_message(client, userdata, msg):
|
|
||||||
if msg.topic == TOPIC_PLAYER_STATUS:
|
|
||||||
try:
|
|
||||||
data = json.loads(msg.payload.decode("utf-8") or "{}")
|
|
||||||
except Exception:
|
|
||||||
return
|
return
|
||||||
|
config = load_config()
|
||||||
state_str = data.get("state")
|
service = ControllerService(config)
|
||||||
slot_val = data.get("slot")
|
service.start()
|
||||||
|
_config = config
|
||||||
if not state_str or slot_val is None:
|
_service = service
|
||||||
return
|
mqtt_client = service.mqtt
|
||||||
|
mqtt_started = True
|
||||||
try:
|
|
||||||
slot_num = int(slot_val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"[controller] player status: slot={slot_num} state={state_str}")
|
|
||||||
_update_slot_state_from_player(slot_num, state_str)
|
|
||||||
|
|
||||||
elif msg.topic == TOPIC_CONTROLLER_CMD:
|
|
||||||
_handle_control_cmd(msg.payload.decode("utf-8", errors="ignore"))
|
|
||||||
|
|
||||||
def _handle_control_cmd(payload: str):
|
|
||||||
"""
|
|
||||||
Handle messages on TOPIC_CONTROLLER_CMD, e.g.:
|
|
||||||
{"action": "delete_slot", "slot": 2, "url": "..."}
|
|
||||||
{"action": "play", "slot": 1}
|
|
||||||
{"action": "stop"}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(payload or "{}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
print(f"[controller] bad JSON on control cmd: {payload!r}")
|
|
||||||
return
|
|
||||||
|
|
||||||
action = data.get("action")
|
|
||||||
if not action:
|
|
||||||
print("[controller] control cmd missing 'action'")
|
|
||||||
return
|
|
||||||
|
|
||||||
# delete slot
|
|
||||||
if action == "delete_slot":
|
|
||||||
slot = data.get("slot")
|
|
||||||
try:
|
|
||||||
slot_num = int(slot)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
print(f"[controller] delete_slot: bad slot {slot!r}")
|
|
||||||
return
|
|
||||||
|
|
||||||
expected_url = data.get("url") # optional safety
|
|
||||||
delete_slot(slot_num, expected_url=expected_url)
|
|
||||||
return
|
|
||||||
|
|
||||||
# play / stop → hand off to the orchestrator
|
|
||||||
if action in ("play", "stop"):
|
|
||||||
handle_play_request(data)
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"[controller] unknown control action: {action!r}")
|
|
||||||
|
|
||||||
def _on_disconnect(client, userdata, rc, properties=None):
|
|
||||||
print(f"[mqtt] disconnected rc={rc}")
|
|
||||||
|
|
||||||
mqtt_client = mqtt.Client(client_id=MQTT_CLIENT_ID, clean_session=True)
|
|
||||||
|
|
||||||
# If your broker ever needs auth, you'd uncomment:
|
|
||||||
# mqtt_client.username_pw_set(os.environ.get("MQTT_USERNAME"), os.environ.get("MQTT_PASSWORD"))
|
|
||||||
|
|
||||||
mqtt_client.enable_logger() # send Paho logs to Python logging
|
|
||||||
mqtt_client.on_connect = _on_connect
|
|
||||||
mqtt_client.on_disconnect = _on_disconnect
|
|
||||||
mqtt_client.on_message = _on_message
|
|
||||||
|
|
||||||
print(f"[controller] attempting MQTT connect to {MQTT_HOST}:{MQTT_PORT} …")
|
|
||||||
mqtt_client.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
|
|
||||||
|
|
||||||
mqtt_client.loop_start()
|
|
||||||
print("[controller] MQTT loop started")
|
|
||||||
|
|
||||||
mqtt_client.on_message = _on_message
|
|
||||||
mqtt_client.subscribe(TOPIC_PLAYER_STATUS, qos=1)
|
|
||||||
mqtt_client.subscribe(TOPIC_CONTROLLER_CMD, qos=1)
|
|
||||||
print(f"[controller] subscribed to {TOPIC_PLAYER_STATUS} and {TOPIC_CONTROLLER_CMD}")
|
|
||||||
|
|
||||||
|
|
||||||
def mqtt_publish(topic: str, payload, **kwargs):
|
def stop_controller() -> None:
|
||||||
"""
|
"""Stop and discard the singleton compatibility service."""
|
||||||
Small wrapper that JSON-encodes non-strings and prints what we publish.
|
|
||||||
Keeps logs useful and avoids repeating code.
|
global _config, _service, mqtt_client, mqtt_started
|
||||||
"""
|
with _lifecycle_lock:
|
||||||
if not isinstance(payload, str):
|
service = _service
|
||||||
payload = json.dumps(payload)
|
if service is not None:
|
||||||
print(f"[mqtt] publish {topic} -> {payload}")
|
service.stop()
|
||||||
mqtt_client.publish(topic, payload, **kwargs)
|
_service = None
|
||||||
|
_config = None
|
||||||
|
mqtt_client = None
|
||||||
|
mqtt_started = False
|
||||||
|
|
||||||
|
|
||||||
# --- State model ---
|
def get_service() -> ControllerService:
|
||||||
|
"""Return the explicitly started compatibility service."""
|
||||||
|
|
||||||
@dataclass
|
with _lifecycle_lock:
|
||||||
class PlaylistSlot:
|
if _service is None or not _service.started:
|
||||||
slot: int
|
raise ControllerUnavailableError(
|
||||||
url: str
|
"Controller is not started; call start_controller() first"
|
||||||
yt_id: Optional[str]
|
|
||||||
title: str
|
|
||||||
path: str
|
|
||||||
m3u: str
|
|
||||||
created_at: float
|
|
||||||
expires_at: float
|
|
||||||
item_count: int
|
|
||||||
state: str # "downloading" | "ready" | "error" | "playing" | "busy"
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
d = asdict(self)
|
|
||||||
# timestamps as ISO-ish strings
|
|
||||||
d["created_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.created_at))
|
|
||||||
d["expires_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.expires_at))
|
|
||||||
return d
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(d: Dict):
|
|
||||||
def parse_ts(s: str) -> float:
|
|
||||||
try:
|
|
||||||
# naive ISO Z
|
|
||||||
return time.mktime(time.strptime(s, "%Y-%m-%dT%H:%M:%SZ"))
|
|
||||||
except Exception:
|
|
||||||
return float(d.get("created_at_ts", time.time()))
|
|
||||||
|
|
||||||
created_at = parse_ts(d.get("created_at", "1970-01-01T00:00:00Z"))
|
|
||||||
expires_at = parse_ts(d.get("expires_at", "1970-01-01T00:00:00Z"))
|
|
||||||
|
|
||||||
return PlaylistSlot(
|
|
||||||
slot=int(d["slot"]),
|
|
||||||
url=d["url"],
|
|
||||||
yt_id=d.get("yt_id"),
|
|
||||||
title=d.get("title", ""),
|
|
||||||
path=d["path"],
|
|
||||||
m3u=d["m3u"],
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
item_count=int(d.get("item_count", 0)),
|
|
||||||
state=d.get("state", "ready"),
|
|
||||||
)
|
)
|
||||||
|
return _service
|
||||||
|
|
||||||
|
|
||||||
class ControllerState:
|
def enqueue_download(
|
||||||
def __init__(self):
|
url: str,
|
||||||
self.slots: Dict[int, PlaylistSlot] = {}
|
submitter: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Queue a download through Controller v2."""
|
||||||
|
|
||||||
def load(self):
|
return get_service().enqueue_download(url, submitter, note)
|
||||||
if not STATE_PATH.exists():
|
|
||||||
return
|
|
||||||
data = yaml.safe_load(STATE_PATH.read_text()) or {}
|
|
||||||
slots = data.get("slots", {})
|
|
||||||
self.slots.clear()
|
|
||||||
for k, v in slots.items():
|
|
||||||
try:
|
|
||||||
slot = PlaylistSlot.from_dict(v)
|
|
||||||
self.slots[slot.slot] = slot
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
data = {"slots": {str(s): slot.to_dict() for s, slot in self.slots.items()}}
|
|
||||||
STATE_PATH.write_text(yaml.safe_dump(data, sort_keys=True))
|
|
||||||
|
|
||||||
def prune_expired(self):
|
|
||||||
"""
|
|
||||||
Remove any slots whose expires_at is in the past, delete their folders,
|
|
||||||
compact remaining slots, save, and reannounce MQTT so ghosts disappear.
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
changed = False
|
|
||||||
to_delete: List[int] = []
|
|
||||||
|
|
||||||
for s, slot in list(self.slots.items()):
|
|
||||||
if now >= slot.expires_at:
|
|
||||||
to_delete.append(s)
|
|
||||||
|
|
||||||
for s in to_delete:
|
|
||||||
slot = self.slots.pop(s, None)
|
|
||||||
if not slot:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
p = Path(slot.path)
|
|
||||||
if p.exists():
|
|
||||||
for child in p.iterdir():
|
|
||||||
try:
|
|
||||||
child.unlink()
|
|
||||||
except IsADirectoryError:
|
|
||||||
# ignore unexpected subdirs
|
|
||||||
pass
|
|
||||||
p.rmdir()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
# compact slot numbers (1..N), save, then resync MQTT topics
|
|
||||||
self.compact_slots()
|
|
||||||
self.save()
|
|
||||||
# this is the helper you already have below
|
|
||||||
reannounce_slots()
|
|
||||||
|
|
||||||
def compact_slots(self):
|
|
||||||
"""Shift slots down so there are no gaps: 1..N"""
|
|
||||||
if not self.slots:
|
|
||||||
return
|
|
||||||
new_slots: Dict[int, PlaylistSlot] = {}
|
|
||||||
for new_index, slot in enumerate(sorted(self.slots.values(), key=lambda sl: sl.slot), start=1):
|
|
||||||
if slot.slot != new_index:
|
|
||||||
old_dir = Path(slot.path)
|
|
||||||
new_dir = MEDIA_BASE / f"slot{new_index}"
|
|
||||||
# rename folder if it exists
|
|
||||||
if old_dir.exists() and old_dir != new_dir:
|
|
||||||
new_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
old_dir.rename(new_dir)
|
|
||||||
slot.slot = new_index
|
|
||||||
slot.path = str(new_dir)
|
|
||||||
slot.m3u = str(new_dir / "playlist.m3u")
|
|
||||||
new_slots[new_index] = slot
|
|
||||||
self.slots = new_slots
|
|
||||||
|
|
||||||
def get_next_free_slot(self) -> Optional[int]:
|
|
||||||
self.prune_expired()
|
|
||||||
used = set(self.slots.keys())
|
|
||||||
for i in range(1, MAX_SLOTS + 1):
|
|
||||||
if i not in used:
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
state = ControllerState()
|
__all__ = [
|
||||||
state.load()
|
"enqueue_download",
|
||||||
state.prune_expired()
|
"get_service",
|
||||||
|
"mqtt_client",
|
||||||
|
"mqtt_started",
|
||||||
# --- Helpers ---
|
"start_controller",
|
||||||
|
"stop_controller",
|
||||||
def delete_slot(slot_num: int, expected_url: Optional[str] = None):
|
|
||||||
"""
|
|
||||||
Delete a slot from state + filesystem, then compact and republish.
|
|
||||||
|
|
||||||
expected_url is optional: if provided and doesn't match, we ignore
|
|
||||||
the delete (safety against stale retained messages if someone ever
|
|
||||||
accidentally turns retain on).
|
|
||||||
"""
|
|
||||||
state.load()
|
|
||||||
|
|
||||||
slot = state.slots.get(slot_num)
|
|
||||||
if not slot:
|
|
||||||
print(f"[controller] delete_slot: slot {slot_num} not found, ignoring")
|
|
||||||
return
|
|
||||||
|
|
||||||
if expected_url and slot.url != expected_url:
|
|
||||||
print(f"[controller] delete_slot: url mismatch for slot {slot_num}, ignoring")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"[controller] deleting slot {slot_num}: {slot.title!r}")
|
|
||||||
|
|
||||||
# Remove files on disk
|
|
||||||
slot_dir = Path(slot.path)
|
|
||||||
if slot_dir.exists():
|
|
||||||
for child in slot_dir.iterdir():
|
|
||||||
try:
|
|
||||||
child.unlink()
|
|
||||||
except IsADirectoryError:
|
|
||||||
# no subdirs expected, but don't crash
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
slot_dir.rmdir()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Remove from state
|
|
||||||
state.slots.pop(slot_num, None)
|
|
||||||
|
|
||||||
# Compact and save
|
|
||||||
state.compact_slots()
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
# Re-publish MQTT for all remaining slots, so HA/Deck are in sync
|
|
||||||
reannounce_slots()
|
|
||||||
|
|
||||||
|
|
||||||
def reannounce_slots():
|
|
||||||
"""
|
|
||||||
Re-publish MQTT state/title/count for all existing slots,
|
|
||||||
and mark any unused slots as empty.
|
|
||||||
"""
|
|
||||||
state.load()
|
|
||||||
|
|
||||||
# Re-announce real slots
|
|
||||||
for s, slot in sorted(state.slots.items()):
|
|
||||||
publish_slot_topics(slot)
|
|
||||||
|
|
||||||
# Clear any slots above the current highest used index
|
|
||||||
highest = max(state.slots.keys(), default=0)
|
|
||||||
for s in range(highest + 1, MAX_SLOTS + 1):
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{s}"
|
|
||||||
mqtt_publish(f"{base}/title", "", retain=True)
|
|
||||||
mqtt_publish(f"{base}/state", "empty", retain=True)
|
|
||||||
mqtt_publish(f"{base}/count", "0", retain=True)
|
|
||||||
# also clear item names
|
|
||||||
for i in range(1, 100):
|
|
||||||
mqtt_publish(f"{base}/items/{i}", "", retain=True)
|
|
||||||
|
|
||||||
|
|
||||||
def publish_slot_topics(slot: PlaylistSlot):
|
|
||||||
"""Publish the title/state/count for one slot to its MQTT topics."""
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{slot.slot}"
|
|
||||||
mqtt_publish(f"{base}/title", slot.title, retain=True)
|
|
||||||
mqtt_publish(f"{base}/state", slot.state, retain=True)
|
|
||||||
mqtt_publish(f"{base}/count", slot.item_count, retain=True)
|
|
||||||
|
|
||||||
def set_slot_busy_temporarily(slot: PlaylistSlot, seconds: int = 5):
|
|
||||||
"""
|
|
||||||
Briefly mark a slot as 'busy', then restore after a delay.
|
|
||||||
"""
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{slot.slot}"
|
|
||||||
mqtt_publish(f"{base}/state", "busy", retain=True)
|
|
||||||
|
|
||||||
def _clear_busy_later(slot_number: int, delay: int):
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
state.load()
|
|
||||||
sl = state.slots.get(slot_number)
|
|
||||||
|
|
||||||
if sl is None:
|
|
||||||
base2 = f"{TOPIC_PLAYBACK_BASE}/{slot_number}"
|
|
||||||
mqtt_publish(f"{base2}/title", "", retain=True)
|
|
||||||
mqtt_publish(f"{base2}/state", "empty", retain=True)
|
|
||||||
mqtt_publish(f"{base2}/count", 0, retain=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
publish_slot_topics(sl)
|
|
||||||
|
|
||||||
t = threading.Thread(
|
|
||||||
target=_clear_busy_later,
|
|
||||||
args=(slot.slot, seconds),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_track_title_from_filename(path: Path) -> str:
|
|
||||||
stem = path.stem # "000 - Song Name [id]"
|
|
||||||
# strip leading "000 - "
|
|
||||||
if " - " in stem:
|
|
||||||
stem = stem.split(" - ", 1)[1]
|
|
||||||
# strip trailing " [id]"
|
|
||||||
if " [" in stem:
|
|
||||||
stem = stem.rsplit(" [", 1)[0]
|
|
||||||
return stem
|
|
||||||
|
|
||||||
|
|
||||||
def _update_slot_state_from_player(slot_num: int, new_state: str):
|
|
||||||
"""
|
|
||||||
Mirror player state ("playing", "busy", "idle", "error", etc.)
|
|
||||||
into our per-slot state + MQTT topics.
|
|
||||||
"""
|
|
||||||
slot_obj = state.slots.get(slot_num)
|
|
||||||
if not slot_obj:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Map "idle" / "finished" from player back to "ready" for the slot UI
|
|
||||||
if new_state in ("idle", "finished"):
|
|
||||||
slot_obj.state = "ready"
|
|
||||||
mqtt_state = "ready"
|
|
||||||
else:
|
|
||||||
slot_obj.state = new_state
|
|
||||||
mqtt_state = new_state
|
|
||||||
|
|
||||||
state.save()
|
|
||||||
publish_slot_topics(slot_obj)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_title_for_url(url: str) -> str:
|
|
||||||
"""
|
|
||||||
Try to detect a human-friendly title for either:
|
|
||||||
- a playlist (use playlist_title)
|
|
||||||
- a single video (fall back to video title)
|
|
||||||
|
|
||||||
If we can't figure it out, just return the URL.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# One line: playlist_title|||video_title
|
|
||||||
out = subprocess.check_output(
|
|
||||||
[
|
|
||||||
"yt-dlp",
|
|
||||||
"--flat-playlist",
|
|
||||||
"--print", "%(playlist_title)s|||%(title)s",
|
|
||||||
"--playlist-end", "1",
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
if not out:
|
|
||||||
return url
|
|
||||||
|
|
||||||
line = out.splitlines()[0]
|
|
||||||
playlist_title, _, video_title = line.partition("|||")
|
|
||||||
|
|
||||||
playlist_title = (playlist_title or "").strip()
|
|
||||||
video_title = (video_title or "").strip()
|
|
||||||
|
|
||||||
# If playlist_title looks real (not empty/NA), use it
|
|
||||||
if playlist_title and playlist_title.upper() != "NA":
|
|
||||||
return playlist_title
|
|
||||||
|
|
||||||
# Otherwise fall back to video title
|
|
||||||
if video_title:
|
|
||||||
return video_title
|
|
||||||
|
|
||||||
return url
|
|
||||||
except Exception:
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def _download_playlist_to_slot(url: str, slot_num: int):
|
|
||||||
"""
|
|
||||||
Runs yt-dlp into /mnt/Media/YTPlayer/slotN, builds playlist.m3u,
|
|
||||||
updates YAML, and publishes MQTT topics.
|
|
||||||
"""
|
|
||||||
slot_dir = MEDIA_BASE / f"slot{slot_num}"
|
|
||||||
slot_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
title = _get_title_for_url(url)
|
|
||||||
now = time.time()
|
|
||||||
expires_at = now + RETENTION_SECONDS
|
|
||||||
|
|
||||||
# mark slot as downloading
|
|
||||||
slot = PlaylistSlot(
|
|
||||||
slot=slot_num,
|
|
||||||
url=url,
|
|
||||||
yt_id=None,
|
|
||||||
title=title,
|
|
||||||
path=str(slot_dir),
|
|
||||||
m3u=str(slot_dir / "playlist.m3u"),
|
|
||||||
created_at=now,
|
|
||||||
expires_at=expires_at,
|
|
||||||
item_count=0,
|
|
||||||
state="downloading",
|
|
||||||
)
|
|
||||||
state.slots[slot_num] = slot
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
# Tell HA/Deck: this slot is downloading now
|
|
||||||
publish_slot_topics(slot)
|
|
||||||
|
|
||||||
mqtt_publish(
|
|
||||||
TOPIC_INGEST_PROGRESS,
|
|
||||||
{"slot": slot_num, "state": "downloading", "title": title},
|
|
||||||
)
|
|
||||||
|
|
||||||
# run yt-dlp
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"yt-dlp",
|
|
||||||
"--cookies",
|
|
||||||
"/cookies.txt", # inside the container (mount your exported cookies here)
|
|
||||||
"-o",
|
|
||||||
str(slot_dir / "%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"),
|
|
||||||
"--yes-playlist",
|
|
||||||
"--no-overwrites", # keep existing files, only fetch new ones
|
|
||||||
"-f",
|
|
||||||
"bv*[height<=1080]+ba/b[height<=1080]/b[height<=1080]/b",
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
slot.state = "error"
|
|
||||||
state.save()
|
|
||||||
publish_slot_topics(slot)
|
|
||||||
mqtt_publish(
|
|
||||||
TOPIC_INGEST_PROGRESS,
|
|
||||||
{"slot": slot_num, "state": "error", "message": f"yt-dlp failed: {e}"},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- build .m3u in sorted order ---
|
|
||||||
media_files = sorted(
|
|
||||||
[
|
|
||||||
p
|
|
||||||
for p in slot_dir.iterdir()
|
|
||||||
if p.is_file() and p.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
|
||||||
]
|
]
|
||||||
)
|
|
||||||
|
|
||||||
if not media_files:
|
|
||||||
slot.state = "error"
|
|
||||||
state.save()
|
|
||||||
publish_slot_topics(slot)
|
|
||||||
mqtt_publish(
|
|
||||||
TOPIC_INGEST_PROGRESS,
|
|
||||||
{"slot": slot_num, "state": "error", "message": "no media files downloaded"},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
item_titles: List[str] = []
|
|
||||||
m3u_path = slot_dir / "playlist.m3u"
|
|
||||||
with m3u_path.open("w", encoding="utf-8") as f:
|
|
||||||
for p in media_files:
|
|
||||||
# Use a path relative to the slot directory so that if the
|
|
||||||
# whole slotN folder gets renamed (compaction), the playlist
|
|
||||||
# still works.
|
|
||||||
rel = os.path.relpath(p, slot_dir)
|
|
||||||
f.write(rel + "\n")
|
|
||||||
item_titles.append(_parse_track_title_from_filename(p))
|
|
||||||
|
|
||||||
|
|
||||||
slot.item_count = len(media_files)
|
|
||||||
slot.state = "ready"
|
|
||||||
state.slots[slot_num] = slot
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
# publish MQTT info for HA + StreamDeck
|
|
||||||
publish_slot_topics(slot)
|
|
||||||
|
|
||||||
# items: 1-based index
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{slot_num}"
|
|
||||||
for i, name in enumerate(item_titles, start=1):
|
|
||||||
mqtt_publish(f"{base}/items/{i}", name, retain=True)
|
|
||||||
|
|
||||||
mqtt_publish(
|
|
||||||
TOPIC_INGEST_PROGRESS,
|
|
||||||
{"slot": slot_num, "state": "ready", "title": title, "count": slot.item_count},
|
|
||||||
)
|
|
||||||
|
|
||||||
def publish_slot_topics(slot: PlaylistSlot):
|
|
||||||
"""Publish the title/state/count for one slot to its MQTT topics."""
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{slot.slot}"
|
|
||||||
mqtt_publish(f"{base}/title", slot.title, retain=True)
|
|
||||||
mqtt_publish(f"{base}/state", slot.state, retain=True)
|
|
||||||
mqtt_publish(f"{base}/count", slot.item_count, retain=True)
|
|
||||||
|
|
||||||
# --- Controller → Player orchestration ---
|
|
||||||
|
|
||||||
def handle_play_request(cmd: Dict) -> None:
|
|
||||||
"""
|
|
||||||
Handle commands from HA/Companion on TOPIC_CONTROLLER_CMD.
|
|
||||||
|
|
||||||
Supported payloads:
|
|
||||||
- {"action": "play", "slot": N}
|
|
||||||
- {"action": "play", "playlist": "/some/path.m3u"} # future perm playlist
|
|
||||||
- {"action": "stop"}
|
|
||||||
"""
|
|
||||||
action = cmd.get("action")
|
|
||||||
|
|
||||||
# Which slot is currently playing?
|
|
||||||
playing_slot: Optional[int] = None
|
|
||||||
for s, slot in state.slots.items():
|
|
||||||
if slot.state == "playing":
|
|
||||||
playing_slot = s
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- STOP command ---
|
|
||||||
if action == "stop":
|
|
||||||
# Forward stop to player
|
|
||||||
mqtt_publish(TOPIC_PLAYER_CMD, {"action": "stop"})
|
|
||||||
|
|
||||||
# Any slot marked playing goes back to ready
|
|
||||||
changed = False
|
|
||||||
for s, slot in state.slots.items():
|
|
||||||
if slot.state == "playing":
|
|
||||||
slot.state = "ready"
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{s}"
|
|
||||||
mqtt_publish(f"{base}/state", "ready", retain=True)
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
state.save()
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- PLAY command ---
|
|
||||||
if action != "play":
|
|
||||||
# ignore unknown for now
|
|
||||||
return
|
|
||||||
|
|
||||||
slot_num_raw = cmd.get("slot")
|
|
||||||
playlist = cmd.get("playlist") or cmd.get("path")
|
|
||||||
label = cmd.get("label")
|
|
||||||
|
|
||||||
slot_obj: Optional[PlaylistSlot] = None
|
|
||||||
|
|
||||||
if slot_num_raw is not None:
|
|
||||||
try:
|
|
||||||
slot_num = int(slot_num_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
print(f"[controller] bad slot in play cmd: {slot_num_raw!r}")
|
|
||||||
return
|
|
||||||
slot_obj = state.slots.get(slot_num)
|
|
||||||
if not slot_obj:
|
|
||||||
print(f"[controller] no such slot {slot_num}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# If we have a slot but no playlist, derive path from the slot.
|
|
||||||
if slot_obj is not None and not playlist:
|
|
||||||
playlist = slot_obj.m3u
|
|
||||||
label = label or slot_obj.title
|
|
||||||
|
|
||||||
# If we still have no playlist/path, bail.
|
|
||||||
if not playlist:
|
|
||||||
print("[controller] play cmd missing playlist/path and no valid slot provided")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Enforce only one slot at a time for now
|
|
||||||
if playing_slot is not None and slot_obj is not None and playing_slot != slot_obj.slot:
|
|
||||||
# briefly mark this requested slot as busy, then restore it
|
|
||||||
set_slot_busy_temporarily(slot_obj, seconds=5)
|
|
||||||
|
|
||||||
mqtt_publish(
|
|
||||||
TOPIC_INGEST_PROGRESS,
|
|
||||||
{
|
|
||||||
"slot": slot_obj.slot,
|
|
||||||
"state": "busy",
|
|
||||||
"message": f"slot {playing_slot} already playing; stop first",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
# If we're starting a slot-backed playlist, mark that slot as playing
|
|
||||||
if slot_obj is not None:
|
|
||||||
slot_obj.state = "playing"
|
|
||||||
state.save()
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{slot_obj.slot}"
|
|
||||||
mqtt_publish(f"{base}/state", "playing", retain=True)
|
|
||||||
|
|
||||||
# Build command for the player
|
|
||||||
play_cmd = {
|
|
||||||
"action": "play",
|
|
||||||
"playlist": playlist,
|
|
||||||
}
|
|
||||||
if slot_obj is not None:
|
|
||||||
play_cmd["slot"] = slot_obj.slot
|
|
||||||
if label:
|
|
||||||
play_cmd["label"] = label
|
|
||||||
|
|
||||||
mqtt_publish(TOPIC_PLAYER_CMD, play_cmd)
|
|
||||||
print(f"[controller] sent play cmd to player: {play_cmd}")
|
|
||||||
|
|
||||||
|
|
||||||
# --- HA Discovery ---
|
|
||||||
|
|
||||||
HA_DISCOVERY_PREFIX = os.environ.get("HA_DISCOVERY_PREFIX", "homeassistant")
|
|
||||||
HA_DEVICE = {
|
|
||||||
"identifiers": ["ytplayer-controller"],
|
|
||||||
"name": "YouTube Controller",
|
|
||||||
"manufacturer": "River of Glory",
|
|
||||||
"model": "YT Controller",
|
|
||||||
}
|
|
||||||
|
|
||||||
def ha_discovery_publish():
|
|
||||||
"""
|
|
||||||
Publish MQTT Discovery configs for:
|
|
||||||
- sensor.yt_slot_<n>_title
|
|
||||||
- sensor.yt_slot_<n>_state
|
|
||||||
- sensor.yt_slot_<n>_count
|
|
||||||
"""
|
|
||||||
for n in range(1, MAX_SLOTS + 1):
|
|
||||||
base = f"{TOPIC_PLAYBACK_BASE}/{n}"
|
|
||||||
obj_id = f"yt_slot_{n}"
|
|
||||||
|
|
||||||
# Title
|
|
||||||
cfg_title = {
|
|
||||||
"name": f"YT Slot {n} Title",
|
|
||||||
"state_topic": f"{base}/title",
|
|
||||||
"unique_id": f"{obj_id}_title",
|
|
||||||
"icon": "mdi:playlist-music",
|
|
||||||
"device": HA_DEVICE,
|
|
||||||
}
|
|
||||||
mqtt_publish(
|
|
||||||
f"{HA_DISCOVERY_PREFIX}/sensor/{obj_id}_title/config",
|
|
||||||
cfg_title,
|
|
||||||
retain=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# State (ready/downloading/playing/busy/error/idle)
|
|
||||||
cfg_state = {
|
|
||||||
"name": f"YT Slot {n} State",
|
|
||||||
"state_topic": f"{base}/state",
|
|
||||||
"unique_id": f"{obj_id}_state",
|
|
||||||
"icon": "mdi:circle-small",
|
|
||||||
"device": HA_DEVICE,
|
|
||||||
}
|
|
||||||
mqtt_publish(
|
|
||||||
f"{HA_DISCOVERY_PREFIX}/sensor/{obj_id}_state/config",
|
|
||||||
cfg_state,
|
|
||||||
retain=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Count
|
|
||||||
cfg_count = {
|
|
||||||
"name": f"YT Slot {n} Count",
|
|
||||||
"state_topic": f"{base}/count",
|
|
||||||
"unique_id": f"{obj_id}_count",
|
|
||||||
"icon": "mdi:numeric",
|
|
||||||
"device": HA_DEVICE,
|
|
||||||
}
|
|
||||||
mqtt_publish(
|
|
||||||
f"{HA_DISCOVERY_PREFIX}/sensor/{obj_id}_count/config",
|
|
||||||
cfg_count,
|
|
||||||
retain=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Public API used by Flask app ---
|
|
||||||
|
|
||||||
def enqueue_download(url: str, submitter: Optional[str], note: Optional[str]) -> Dict:
|
|
||||||
"""
|
|
||||||
Called by Flask when a new playlist is submitted.
|
|
||||||
- If this URL is already in a slot, reuse that slot and only grab new videos.
|
|
||||||
- Otherwise, allocate a new slot.
|
|
||||||
Returns a small dict summarizing what happened, for UI or HA.
|
|
||||||
"""
|
|
||||||
state.load()
|
|
||||||
state.prune_expired()
|
|
||||||
|
|
||||||
# 1) Check if this URL already exists in a slot
|
|
||||||
existing_slot_obj = None
|
|
||||||
for sl in state.slots.values():
|
|
||||||
if sl.url == url:
|
|
||||||
existing_slot_obj = sl
|
|
||||||
break
|
|
||||||
|
|
||||||
if existing_slot_obj:
|
|
||||||
slot_num = existing_slot_obj.slot
|
|
||||||
print(f"[controller] enqueue_download: reusing existing slot {slot_num} for URL {url!r}")
|
|
||||||
reused = True
|
|
||||||
else:
|
|
||||||
# 2) Allocate a new slot
|
|
||||||
slot_num = state.get_next_free_slot()
|
|
||||||
reused = False
|
|
||||||
|
|
||||||
if slot_num is None:
|
|
||||||
# queue full
|
|
||||||
expires = [sl.expires_at for sl in state.slots.values()]
|
|
||||||
oldest = min(expires) if expires else time.time()
|
|
||||||
return {
|
|
||||||
"status": "queue_full",
|
|
||||||
"oldest_expires_at": oldest,
|
|
||||||
"oldest_expires_in_seconds": int(oldest - time.time()),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3) Spawn background thread so Flask doesn't block
|
|
||||||
t = threading.Thread(
|
|
||||||
target=_download_playlist_to_slot,
|
|
||||||
args=(url, slot_num),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
# 4) Simple MQTT log of submission
|
|
||||||
mqtt_publish(
|
|
||||||
TOPIC_INGEST_PROGRESS,
|
|
||||||
{
|
|
||||||
"slot": slot_num,
|
|
||||||
"state": "queued",
|
|
||||||
"url": url,
|
|
||||||
"submitter": submitter,
|
|
||||||
"note": note,
|
|
||||||
"reused_slot": reused,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "queued",
|
|
||||||
"slot": slot_num,
|
|
||||||
"reused_slot": reused,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Post-define initialization that needs functions above ---
|
|
||||||
|
|
||||||
print("[controller] running HA discovery publish…")
|
|
||||||
ha_discovery_publish()
|
|
||||||
print("[controller] ha_discovery_publish() done")
|
|
||||||
|
|
||||||
# Re-announce any existing slots so HA / Deck see them after a restart
|
|
||||||
for s, slot in state.slots.items():
|
|
||||||
publish_slot_topics(slot)
|
|
||||||
print("[controller] re-published slot states")
|
|
||||||
|
|||||||
+51
-9
@@ -1,22 +1,64 @@
|
|||||||
services:
|
services:
|
||||||
controller:
|
controller:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
CONTROLLER_UID: ${CONTROLLER_UID:-1000}
|
||||||
|
CONTROLLER_GID: ${CONTROLLER_GID:-1000}
|
||||||
|
image: video-player-controller:local
|
||||||
container_name: yt-controller
|
container_name: yt-controller
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
ports:
|
ports:
|
||||||
- "7531:7531"
|
- "${CONTROLLER_WEB_PORT:-7531}:7531"
|
||||||
environment:
|
environment:
|
||||||
- FLASK_SECRET_KEY=change-me
|
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:?Set FLASK_SECRET_KEY in .env}
|
||||||
- MQTT_HOST=10.40.0.2
|
MQTT_HOST: ${MQTT_HOST:-10.40.0.2}
|
||||||
- MQTT_PORT=1883
|
MQTT_PORT: ${MQTT_PORT:-1883}
|
||||||
|
MQTT_USERNAME: ${MQTT_USERNAME:-}
|
||||||
|
MQTT_PASSWORD: ${MQTT_PASSWORD:-}
|
||||||
|
MQTT_CLIENT_ID: ${MQTT_CLIENT_ID:-yt-controller}
|
||||||
|
PORT: 7531
|
||||||
|
YTPLAYER_DATA_BASE: /controller-data
|
||||||
|
YTPLAYER_MEDIA_BASE: /mnt/Media/YTPlayer
|
||||||
|
YTPLAYER_COOKIE_PATH: /run/secrets/yt-dlp-cookies.txt
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/Media:/mnt/Media # same path inside container
|
- ${CONTROLLER_DATA_DIR:?Set CONTROLLER_DATA_DIR in .env}:/controller-data
|
||||||
- /home/media/Scripts/Video-Player:/controller-data
|
- ${YTPLAYER_MEDIA_DIR:-/mnt/Media/YTPlayer}:/mnt/Media/YTPlayer
|
||||||
- /home/media/Scripts/Video-Player/cookies.txt:/cookies_ro.txt:ro
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
# Optional: place yt-dlp-cookies.txt in this host directory.
|
||||||
|
- type: bind
|
||||||
|
source: ${CONTROLLER_SECRETS_DIR:-./secrets}
|
||||||
|
target: /run/secrets
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,nosuid,nodev,noexec,mode=1777,size=256m
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- >-
|
||||||
|
import urllib.request;
|
||||||
|
urllib.request.urlopen('http://127.0.0.1:7531/healthz', timeout=3).read()
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 30s
|
||||||
|
retries: 3
|
||||||
|
stop_grace_period: 45s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
networks:
|
networks:
|
||||||
- tabernet
|
- tabernet
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
tabernet:
|
tabernet:
|
||||||
external: true
|
external: true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Single-process runtime: ControllerService owns one local coordinator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from controller.config import load_config
|
||||||
|
|
||||||
|
|
||||||
|
_web_config = load_config().web
|
||||||
|
bind = f"{_web_config.host}:{_web_config.port}"
|
||||||
|
workers = 1
|
||||||
|
worker_class = "gthread"
|
||||||
|
threads = 4
|
||||||
|
preload_app = False
|
||||||
|
accesslog = "-"
|
||||||
|
errorlog = "-"
|
||||||
|
capture_output = True
|
||||||
|
timeout = 30
|
||||||
|
graceful_timeout = 30
|
||||||
|
worker_tmp_dir = "/tmp"
|
||||||
|
control_socket_disable = True
|
||||||
|
|
||||||
|
|
||||||
|
def worker_exit(server, worker): # pragma: no cover - exercised by Gunicorn
|
||||||
|
module = sys.modules.get("wsgi")
|
||||||
|
service = getattr(module, "service", None)
|
||||||
|
if service is None:
|
||||||
|
return
|
||||||
|
service.stop()
|
||||||
+14
-4
@@ -1,4 +1,14 @@
|
|||||||
flask
|
Flask==3.1.3
|
||||||
paho-mqtt
|
gunicorn==26.0.0
|
||||||
yt-dlp
|
paho-mqtt==2.1.0
|
||||||
pyyaml
|
PyYAML==6.0.3
|
||||||
|
yt-dlp[default,pin]==2026.7.4
|
||||||
|
|
||||||
|
# Exact Flask/Gunicorn transitive set used by the production image.
|
||||||
|
blinker==1.9.0
|
||||||
|
click==8.4.2
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
packaging==26.3
|
||||||
|
Werkzeug==3.1.8
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Controller regression tests."""
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from controller.config import ControllerConfig
|
||||||
|
from controller.models import StoreError
|
||||||
|
from controller.mqtt_adapter import MqttAdapter
|
||||||
|
from controller.service import ControllerService, ControllerUnavailableError
|
||||||
|
|
||||||
|
|
||||||
|
class FakeControllerService:
|
||||||
|
def __init__(self, *, enqueue_result=None, enqueue_error=None, health=None):
|
||||||
|
self.enqueue_result = enqueue_result or {
|
||||||
|
"status": "queued",
|
||||||
|
"slot": 3,
|
||||||
|
"reused_slot": False,
|
||||||
|
}
|
||||||
|
self.enqueue_error = enqueue_error
|
||||||
|
self.health_result = health or {
|
||||||
|
"status": "ok",
|
||||||
|
"started": True,
|
||||||
|
"coordinator_alive": True,
|
||||||
|
"mqtt_connected": True,
|
||||||
|
"router_state": "video",
|
||||||
|
"degraded_reasons": [],
|
||||||
|
}
|
||||||
|
self.enqueue_calls = []
|
||||||
|
|
||||||
|
def enqueue_download(self, url, submitter, note):
|
||||||
|
self.enqueue_calls.append((url, submitter, note))
|
||||||
|
if self.enqueue_error is not None:
|
||||||
|
raise self.enqueue_error
|
||||||
|
return dict(self.enqueue_result)
|
||||||
|
|
||||||
|
def health(self):
|
||||||
|
return dict(self.health_result)
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskFactoryTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = ControllerConfig()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _factory():
|
||||||
|
module = importlib.import_module("app")
|
||||||
|
return module.create_app
|
||||||
|
|
||||||
|
def _app(self, service):
|
||||||
|
return self._factory()(self.config, service, testing=True)
|
||||||
|
|
||||||
|
def _post(
|
||||||
|
self,
|
||||||
|
service,
|
||||||
|
*,
|
||||||
|
url="https://www.youtube.com/playlist?list=PL1234567890",
|
||||||
|
**fields,
|
||||||
|
):
|
||||||
|
app = self._app(service)
|
||||||
|
return app.test_client().post(
|
||||||
|
"/submit",
|
||||||
|
data={"url": url, **fields},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_index_preserves_submission_form(self):
|
||||||
|
response = self._app(FakeControllerService()).test_client().get("/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Submit YouTube Playlist", response.data)
|
||||||
|
self.assertIn(b'action="/submit"', response.data)
|
||||||
|
self.assertIn(b'name="url"', response.data)
|
||||||
|
self.assertIn(b'name="submitter"', response.data)
|
||||||
|
self.assertIn(b'name="note"', response.data)
|
||||||
|
|
||||||
|
def test_submit_rejects_non_youtube_and_malformed_urls(self):
|
||||||
|
invalid_urls = (
|
||||||
|
"",
|
||||||
|
"http",
|
||||||
|
"https:///missing-host",
|
||||||
|
"httpfoo://youtube.com/video",
|
||||||
|
"ftp://youtube.com/video",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"/relative/path",
|
||||||
|
# Arbitrary external hosts, including lookalikes and substring
|
||||||
|
# tricks, must be rejected by exact normalized hostname match.
|
||||||
|
"https://example.com/video",
|
||||||
|
"https://notyoutube.com/video",
|
||||||
|
"https://youtube.com.evil.com/video",
|
||||||
|
"https://evil.com/?u=youtube.com",
|
||||||
|
"https://xyoutube.com/watch?v=abc",
|
||||||
|
"https://youtube.com.attacker.net/watch?v=abc",
|
||||||
|
# Embedded userinfo must be rejected even when the host matches.
|
||||||
|
"https://user:[email protected]/watch?v=abc",
|
||||||
|
"https://[email protected]/watch?v=abc",
|
||||||
|
"https://[email protected]/watch?v=abc",
|
||||||
|
# Oversized URL.
|
||||||
|
"https://www.youtube.com/watch?v=" + ("a" * 3000),
|
||||||
|
)
|
||||||
|
for url in invalid_urls:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
service = FakeControllerService()
|
||||||
|
response = self._post(service, url=url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Invalid URL", response.data)
|
||||||
|
self.assertEqual(service.enqueue_calls, [])
|
||||||
|
|
||||||
|
def test_submit_accepts_only_explicit_official_youtube_hosts(self):
|
||||||
|
valid_urls = (
|
||||||
|
"https://youtube.com/watch?v=abc",
|
||||||
|
"https://www.youtube.com/watch?v=abc",
|
||||||
|
"http://www.youtube.com/watch?v=abc",
|
||||||
|
"https://m.youtube.com/watch?v=abc",
|
||||||
|
"https://music.youtube.com/watch?v=abc",
|
||||||
|
"https://youtu.be/abc",
|
||||||
|
"https://YouTube.com/watch?v=abc",
|
||||||
|
"https://WWW.YOUTUBE.COM/watch?v=abc",
|
||||||
|
)
|
||||||
|
for url in valid_urls:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
service = FakeControllerService()
|
||||||
|
response = self._post(service, url=url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(service.enqueue_calls, [(url, None, None)])
|
||||||
|
|
||||||
|
def test_queued_submission_preserves_message_and_trims_optional_fields(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_result={"status": "queued", "slot": 3, "reused_slot": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(
|
||||||
|
service,
|
||||||
|
url="https://www.youtube.com/playlist?list=PL1234567890",
|
||||||
|
submitter=" Alice ",
|
||||||
|
note=" Before service ",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Playlist queued in slot 3", response.data)
|
||||||
|
self.assertIn(b"start downloading shortly", response.data)
|
||||||
|
self.assertEqual(
|
||||||
|
service.enqueue_calls,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"https://www.youtube.com/playlist?list=PL1234567890",
|
||||||
|
"Alice",
|
||||||
|
"Before service",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_submit_rejects_oversized_submitter_and_note(self):
|
||||||
|
module = importlib.import_module("app")
|
||||||
|
service = FakeControllerService()
|
||||||
|
response = self._post(
|
||||||
|
service,
|
||||||
|
submitter="x" * (module.MAX_SUBMITTER_LENGTH + 1),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"must be", response.data)
|
||||||
|
self.assertEqual(service.enqueue_calls, [])
|
||||||
|
|
||||||
|
service = FakeControllerService()
|
||||||
|
response = self._post(
|
||||||
|
service,
|
||||||
|
note="x" * (module.MAX_NOTE_LENGTH + 1),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"must be", response.data)
|
||||||
|
self.assertEqual(service.enqueue_calls, [])
|
||||||
|
|
||||||
|
def test_submit_rejects_oversized_request_body(self):
|
||||||
|
service = FakeControllerService()
|
||||||
|
app = self._app(service)
|
||||||
|
response = app.test_client().post(
|
||||||
|
"/submit",
|
||||||
|
data={
|
||||||
|
"url": "https://www.youtube.com/watch?v=abc",
|
||||||
|
"note": "x" * 64_000,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"too large", response.data.lower())
|
||||||
|
self.assertEqual(service.enqueue_calls, [])
|
||||||
|
|
||||||
|
def test_reused_submission_preserves_existing_slot_message(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_result={"status": "queued", "slot": 7, "reused_slot": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(service)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Playlist queued in slot 7", response.data)
|
||||||
|
self.assertIn(b"Existing slot", response.data)
|
||||||
|
self.assertIn(b"only new videos will be added", response.data)
|
||||||
|
|
||||||
|
def test_queue_full_has_a_useful_capacity_message(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_result={
|
||||||
|
"status": "queue_full",
|
||||||
|
"slot": None,
|
||||||
|
"reused_slot": False,
|
||||||
|
"oldest_expires_in_seconds": 7200,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(service)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Queue is full", response.data)
|
||||||
|
self.assertIn(b"about 2 hour(s)", response.data)
|
||||||
|
self.assertNotIn(b"slot None", response.data)
|
||||||
|
|
||||||
|
def test_shutting_down_has_a_useful_retry_message(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_result={
|
||||||
|
"status": "shutting_down",
|
||||||
|
"slot": None,
|
||||||
|
"reused_slot": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(service)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"shutting down", response.data.lower())
|
||||||
|
self.assertIn(b"try again", response.data.lower())
|
||||||
|
self.assertNotIn(b"slot None", response.data)
|
||||||
|
|
||||||
|
def test_worker_error_has_a_useful_failure_message(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_result={
|
||||||
|
"status": "worker_error",
|
||||||
|
"slot": 4,
|
||||||
|
"reused_slot": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(service)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"download worker", response.data.lower())
|
||||||
|
self.assertTrue(
|
||||||
|
b"could not" in response.data.lower()
|
||||||
|
or b"failed" in response.data.lower()
|
||||||
|
)
|
||||||
|
self.assertNotIn(b"Playlist queued", response.data)
|
||||||
|
|
||||||
|
def test_controller_unavailable_is_handled_without_a_500(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_error=ControllerUnavailableError(
|
||||||
|
"Controller coordinator did not respond"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(service)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn(b"Internal Server Error", response.data)
|
||||||
|
self.assertIn(b"Controller", response.data)
|
||||||
|
self.assertIn(b"unavailable", response.data.lower())
|
||||||
|
|
||||||
|
def test_store_error_is_handled_without_a_500(self):
|
||||||
|
service = FakeControllerService(
|
||||||
|
enqueue_error=StoreError("Controller database is unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._post(service)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn(b"Internal Server Error", response.data)
|
||||||
|
self.assertIn(b"Controller", response.data)
|
||||||
|
self.assertIn(b"unavailable", response.data.lower())
|
||||||
|
|
||||||
|
def test_health_is_healthy_when_ok_or_degraded(self):
|
||||||
|
cases = (
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"started": True,
|
||||||
|
"coordinator_alive": True,
|
||||||
|
"mqtt_connected": True,
|
||||||
|
"degraded_reasons": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "degraded",
|
||||||
|
"started": True,
|
||||||
|
"coordinator_alive": True,
|
||||||
|
"mqtt_connected": False,
|
||||||
|
"degraded_reasons": ["mqtt_disconnected"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for health in cases:
|
||||||
|
with self.subTest(status=health["status"]):
|
||||||
|
app = self._app(FakeControllerService(health=health))
|
||||||
|
response = app.test_client().get("/healthz")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.get_json(), health)
|
||||||
|
|
||||||
|
def test_health_is_unhealthy_when_stopped_or_coordinator_is_dead(self):
|
||||||
|
cases = (
|
||||||
|
{
|
||||||
|
"status": "stopped",
|
||||||
|
"started": False,
|
||||||
|
"coordinator_alive": False,
|
||||||
|
"mqtt_connected": False,
|
||||||
|
"degraded_reasons": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "degraded",
|
||||||
|
"started": True,
|
||||||
|
"coordinator_alive": False,
|
||||||
|
"mqtt_connected": False,
|
||||||
|
"degraded_reasons": ["coordinator_dead"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for health in cases:
|
||||||
|
with self.subTest(status=health["status"]):
|
||||||
|
app = self._app(FakeControllerService(health=health))
|
||||||
|
response = app.test_client().get("/healthz")
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertEqual(response.get_json(), health)
|
||||||
|
|
||||||
|
def test_production_factory_requires_flask_secret_key(self):
|
||||||
|
with mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "FLASK_SECRET_KEY"):
|
||||||
|
self._factory()(self.config, FakeControllerService())
|
||||||
|
|
||||||
|
def test_testing_factory_can_use_a_test_only_secret(self):
|
||||||
|
with mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
app = self._factory()(
|
||||||
|
self.config,
|
||||||
|
FakeControllerService(),
|
||||||
|
testing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(app.testing, True)
|
||||||
|
self.assertTrue(app.secret_key)
|
||||||
|
self.assertNotEqual(app.secret_key, "dev-secret")
|
||||||
|
|
||||||
|
|
||||||
|
class AppImportLifecycleTests(unittest.TestCase):
|
||||||
|
def test_import_does_not_construct_or_start_controller_runtime(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
media = Path(directory) / "media"
|
||||||
|
data = Path(directory) / "data"
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"YTPLAYER_MEDIA_BASE": str(media),
|
||||||
|
"YTPLAYER_DATA_BASE": str(data),
|
||||||
|
}
|
||||||
|
environment.pop("FLASK_SECRET_KEY", None)
|
||||||
|
before_threads = {thread.ident for thread in threading.enumerate()}
|
||||||
|
sys.modules.pop("app", None)
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, environment, clear=True):
|
||||||
|
with mock.patch.object(
|
||||||
|
ControllerService,
|
||||||
|
"__init__",
|
||||||
|
side_effect=AssertionError("import constructed ControllerService"),
|
||||||
|
), mock.patch.object(
|
||||||
|
ControllerService,
|
||||||
|
"start",
|
||||||
|
side_effect=AssertionError("import started ControllerService"),
|
||||||
|
), mock.patch.object(
|
||||||
|
MqttAdapter,
|
||||||
|
"start",
|
||||||
|
side_effect=AssertionError("import started MQTT"),
|
||||||
|
):
|
||||||
|
imported = importlib.import_module("app")
|
||||||
|
|
||||||
|
self.assertTrue(callable(imported.create_app))
|
||||||
|
self.assertFalse(media.exists())
|
||||||
|
self.assertFalse(data.exists())
|
||||||
|
self.assertEqual(
|
||||||
|
{thread.ident for thread in threading.enumerate()},
|
||||||
|
before_threads,
|
||||||
|
)
|
||||||
|
sys.modules.pop("app", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from controller.config import ConfigError, load_config
|
||||||
|
from controller.contracts import (
|
||||||
|
PLAYER_COMMAND_QOS,
|
||||||
|
PLAYER_COMMAND_RETAIN,
|
||||||
|
SLOT_IDS,
|
||||||
|
Topics,
|
||||||
|
)
|
||||||
|
from controller.ha import HA_DEVICE, SlotAnnouncement, discovery_messages, slot_messages
|
||||||
|
|
||||||
|
|
||||||
|
class ContractCharacterizationTests(unittest.TestCase):
|
||||||
|
def test_player_and_legacy_topic_defaults_are_preserved(self):
|
||||||
|
topics = Topics()
|
||||||
|
self.assertEqual(topics.player_command, "youtube/player/cmd")
|
||||||
|
self.assertEqual(topics.player_status, "youtube/player/status")
|
||||||
|
self.assertEqual(topics.player_state, "youtube/player/state")
|
||||||
|
self.assertEqual(topics.player_telemetry, "youtube/player/telemetry")
|
||||||
|
self.assertEqual(topics.controller_command, "youtube/controller/cmd")
|
||||||
|
self.assertEqual(topics.ingest_progress, "youtube/ingest/progress")
|
||||||
|
self.assertEqual(topics.playback_base, "youtube/media/playback")
|
||||||
|
self.assertEqual(topics.router_mode_set, "audio/player/mode/set")
|
||||||
|
self.assertEqual(topics.router_mode_state, "audio/player/mode/state")
|
||||||
|
self.assertEqual(topics.controller_result, "youtube/controller/v2/events")
|
||||||
|
self.assertEqual(PLAYER_COMMAND_QOS, 1)
|
||||||
|
self.assertIs(PLAYER_COMMAND_RETAIN, False)
|
||||||
|
self.assertEqual(SLOT_IDS, tuple(range(1, 11)))
|
||||||
|
|
||||||
|
def test_existing_ha_discovery_ids_payloads_and_retain_are_preserved(self):
|
||||||
|
config = load_config(environ={})
|
||||||
|
messages = list(discovery_messages(config.topics, config.homeassistant))
|
||||||
|
self.assertEqual(len(messages), 30)
|
||||||
|
topic, payload, qos, retain = messages[0]
|
||||||
|
self.assertEqual(topic, "homeassistant/sensor/yt_slot_1_title/config")
|
||||||
|
self.assertEqual(qos, 0)
|
||||||
|
self.assertIs(retain, True)
|
||||||
|
decoded = json.loads(payload)
|
||||||
|
self.assertEqual(decoded["unique_id"], "yt_slot_1_title")
|
||||||
|
self.assertEqual(decoded["state_topic"], "youtube/media/playback/1/title")
|
||||||
|
self.assertEqual(decoded["device"], HA_DEVICE)
|
||||||
|
self.assertEqual(messages[-1][0], "homeassistant/sensor/yt_slot_10_count/config")
|
||||||
|
|
||||||
|
def test_slot_topics_remain_plain_retained_values_and_clear_item_tail(self):
|
||||||
|
announcement = SlotAnnouncement(
|
||||||
|
slot=4,
|
||||||
|
title="Service",
|
||||||
|
state="ready",
|
||||||
|
count=2,
|
||||||
|
items=("One", "Two"),
|
||||||
|
)
|
||||||
|
messages = list(slot_messages(announcement, Topics(), clear_items_through=4))
|
||||||
|
self.assertEqual(
|
||||||
|
messages,
|
||||||
|
[
|
||||||
|
("youtube/media/playback/4/title", "Service", 0, True),
|
||||||
|
("youtube/media/playback/4/state", "ready", 0, True),
|
||||||
|
("youtube/media/playback/4/count", "2", 0, True),
|
||||||
|
("youtube/media/playback/4/items/1", "One", 0, True),
|
||||||
|
("youtube/media/playback/4/items/2", "Two", 0, True),
|
||||||
|
("youtube/media/playback/4/items/3", "", 0, True),
|
||||||
|
("youtube/media/playback/4/items/4", "", 0, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationTests(unittest.TestCase):
|
||||||
|
def test_safe_router_defaults_are_finite_and_service_usable(self):
|
||||||
|
config = load_config(environ={})
|
||||||
|
self.assertEqual(config.router.required_mode, "video")
|
||||||
|
self.assertEqual(config.router.confirmation_timeout_seconds, 3.0)
|
||||||
|
self.assertEqual(config.router.when_state_unknown, "attempt")
|
||||||
|
self.assertIs(config.router.continue_on_confirmation_failure, True)
|
||||||
|
self.assertIsNone(config.router.fallback_restore_mode)
|
||||||
|
self.assertEqual(config.storage.uncertain_review_after_seconds, 900.0)
|
||||||
|
self.assertEqual(config.mqtt.publish_ack_timeout_seconds, 2.0)
|
||||||
|
self.assertEqual(config.mqtt.router_echo_grace_seconds, 2.0)
|
||||||
|
|
||||||
|
def test_documented_environment_overrides_apply(self):
|
||||||
|
env = {
|
||||||
|
"MQTT_HOST": "broker",
|
||||||
|
"MQTT_PORT": "2883",
|
||||||
|
"MQTT_USERNAME": "user",
|
||||||
|
"MQTT_PASSWORD": "pass",
|
||||||
|
"YTPLAYER_MEDIA_BASE": "/media-test",
|
||||||
|
"YTPLAYER_DATA_BASE": "/data-test",
|
||||||
|
"YTPLAYER_COOKIE_PATH": "",
|
||||||
|
"PORT": "9000",
|
||||||
|
}
|
||||||
|
config = load_config(environ=env)
|
||||||
|
self.assertEqual(config.mqtt.host, "broker")
|
||||||
|
self.assertEqual(config.mqtt.port, 2883)
|
||||||
|
self.assertEqual(config.mqtt.username, "user")
|
||||||
|
self.assertEqual(config.mqtt.password, "pass")
|
||||||
|
self.assertEqual(config.storage.media_base, Path("/media-test"))
|
||||||
|
self.assertEqual(config.storage.data_base, Path("/data-test"))
|
||||||
|
self.assertIsNone(config.downloads.cookie_path)
|
||||||
|
self.assertEqual(config.web.port, 9000)
|
||||||
|
|
||||||
|
def test_blank_optional_mqtt_credentials_are_treated_as_unset(self):
|
||||||
|
config = load_config(
|
||||||
|
environ={
|
||||||
|
"MQTT_USERNAME": "",
|
||||||
|
"MQTT_PASSWORD": " ",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIsNone(config.mqtt.username)
|
||||||
|
self.assertIsNone(config.mqtt.password)
|
||||||
|
|
||||||
|
def test_blank_compose_credentials_do_not_mask_yaml_credentials(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.yaml"
|
||||||
|
path.write_text(
|
||||||
|
"mqtt:\n username: yaml-user\n password: yaml-pass\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
config = load_config(
|
||||||
|
path,
|
||||||
|
environ={"MQTT_USERNAME": "", "MQTT_PASSWORD": ""},
|
||||||
|
)
|
||||||
|
self.assertEqual(config.mqtt.username, "yaml-user")
|
||||||
|
self.assertEqual(config.mqtt.password, "yaml-pass")
|
||||||
|
|
||||||
|
def test_slot_count_cannot_break_stable_identities(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.yaml"
|
||||||
|
path.write_text("storage:\n slot_count: 9\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ConfigError, "fixed at 10"):
|
||||||
|
load_config(path, environ={})
|
||||||
|
|
||||||
|
def test_invalid_router_unknown_state_policy_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.yaml"
|
||||||
|
path.write_text("router:\n when_state_unknown: guess\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ConfigError, "attempt, continue, or reject"):
|
||||||
|
load_config(path, environ={})
|
||||||
|
|
||||||
|
def test_non_finite_timeouts_are_rejected(self):
|
||||||
|
for value in (".nan", ".inf", "-.inf"):
|
||||||
|
with self.subTest(value=value), tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.yaml"
|
||||||
|
path.write_text(
|
||||||
|
f"router:\n confirmation_timeout_seconds: {value}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ConfigError, "must be finite"):
|
||||||
|
load_config(path, environ={})
|
||||||
|
|
||||||
|
def test_negative_router_echo_grace_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.yaml"
|
||||||
|
path.write_text(
|
||||||
|
"mqtt:\n router_echo_grace_seconds: -1\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ConfigError, "router_echo_grace_seconds"):
|
||||||
|
load_config(path, environ={})
|
||||||
|
|
||||||
|
def test_runtime_paths_must_be_absolute(self):
|
||||||
|
cases = (
|
||||||
|
("storage:\n media_base: relative/media\n", "storage.media_base"),
|
||||||
|
("storage:\n data_base: relative/data\n", "storage.data_base"),
|
||||||
|
("downloads:\n cookie_path: relative/cookies.txt\n", "downloads.cookie_path"),
|
||||||
|
)
|
||||||
|
for contents, field in cases:
|
||||||
|
with self.subTest(field=field), tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.yaml"
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ConfigError, f"{field} must be an absolute path"):
|
||||||
|
load_config(path, environ={})
|
||||||
|
|
||||||
|
|
||||||
|
class ImportLifecycleTests(unittest.TestCase):
|
||||||
|
def test_controller_core_import_does_not_connect_or_create_runtime_paths(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
media = Path(directory) / "media"
|
||||||
|
data = Path(directory) / "data"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"YTPLAYER_MEDIA_BASE": str(media),
|
||||||
|
"YTPLAYER_DATA_BASE": str(data),
|
||||||
|
}
|
||||||
|
sys.modules.pop("controller_core", None)
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True):
|
||||||
|
module = importlib.import_module("controller_core")
|
||||||
|
self.assertFalse(media.exists())
|
||||||
|
self.assertFalse(data.exists())
|
||||||
|
self.assertIsNone(module.mqtt_client)
|
||||||
|
self.assertIs(module.mqtt_started, False)
|
||||||
|
sys.modules.pop("controller_core", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import Future
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from controller.config import ControllerConfig, DownloadConfig, StorageConfig
|
||||||
|
from controller.downloads import DownloadManager, canonical_playlist_path
|
||||||
|
from controller.models import SlotProtectedError
|
||||||
|
from controller.store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
class ImmediateExecutor:
|
||||||
|
def submit(self, function, *args, **kwargs):
|
||||||
|
future = Future()
|
||||||
|
try:
|
||||||
|
future.set_result(function(*args, **kwargs))
|
||||||
|
except BaseException as exc:
|
||||||
|
future.set_exception(exc)
|
||||||
|
return future
|
||||||
|
|
||||||
|
|
||||||
|
class QueuedExecutor:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def submit(self, function, *args, **kwargs):
|
||||||
|
future = Future()
|
||||||
|
self.calls.append((future, function, args, kwargs))
|
||||||
|
return future
|
||||||
|
|
||||||
|
def run_next(self):
|
||||||
|
future, function, args, kwargs = self.calls.pop(0)
|
||||||
|
try:
|
||||||
|
future.set_result(function(*args, **kwargs))
|
||||||
|
except BaseException as exc:
|
||||||
|
future.set_exception(exc)
|
||||||
|
return future
|
||||||
|
|
||||||
|
|
||||||
|
class FakeYtDlp:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
media_base: Path,
|
||||||
|
*,
|
||||||
|
fail_download=False,
|
||||||
|
no_media=False,
|
||||||
|
rewrite_cookie=False,
|
||||||
|
):
|
||||||
|
self.media_base = media_base
|
||||||
|
self.fail_download = fail_download
|
||||||
|
self.no_media = no_media
|
||||||
|
self.rewrite_cookie = rewrite_cookie
|
||||||
|
self.calls = []
|
||||||
|
self.cookie_observations = []
|
||||||
|
|
||||||
|
def __call__(self, arguments, **kwargs):
|
||||||
|
self.calls.append((list(arguments), dict(kwargs)))
|
||||||
|
if "--cookies" in arguments:
|
||||||
|
cookie_path = Path(arguments[arguments.index("--cookies") + 1])
|
||||||
|
self.cookie_observations.append(
|
||||||
|
(
|
||||||
|
cookie_path,
|
||||||
|
cookie_path.read_text(encoding="utf-8"),
|
||||||
|
stat.S_IMODE(cookie_path.stat().st_mode),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.rewrite_cookie:
|
||||||
|
cookie_path.write_text("updated by yt-dlp", encoding="utf-8")
|
||||||
|
if "--flat-playlist" in arguments:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
arguments,
|
||||||
|
0,
|
||||||
|
stdout="Test Playlist|||First Video|||playlist-id\n",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
if self.fail_download:
|
||||||
|
raise subprocess.CalledProcessError(7, arguments)
|
||||||
|
if not self.no_media:
|
||||||
|
output_index = arguments.index("-o") + 1
|
||||||
|
output_template = Path(arguments[output_index])
|
||||||
|
directory = output_template.parent
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / "001 - First [one].mp4").write_bytes(b"video-one")
|
||||||
|
(directory / "002 - Second [two].webm").write_bytes(b"video-two")
|
||||||
|
return subprocess.CompletedProcess(arguments, 0, stdout="", stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManagerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
base = Path(self.tempdir.name)
|
||||||
|
self.media = base / "media"
|
||||||
|
self.data = base / "data"
|
||||||
|
self.cookie = base / "cookies.txt"
|
||||||
|
self.cookie.write_text("cookie data", encoding="utf-8")
|
||||||
|
self.config = ControllerConfig(
|
||||||
|
storage=StorageConfig(
|
||||||
|
media_base=self.media,
|
||||||
|
data_base=self.data,
|
||||||
|
retention_seconds=100,
|
||||||
|
),
|
||||||
|
downloads=DownloadConfig(
|
||||||
|
yt_dlp_binary="yt-dlp-test",
|
||||||
|
cookie_path=self.cookie,
|
||||||
|
cookie_required=True,
|
||||||
|
max_workers=1,
|
||||||
|
process_timeout_seconds=30,
|
||||||
|
max_height=720,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.store = ControllerStore(self.config.storage).open()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
|
||||||
|
def manager(self, runner, events=None):
|
||||||
|
event_list = events if events is not None else []
|
||||||
|
return DownloadManager(
|
||||||
|
self.config,
|
||||||
|
self.store,
|
||||||
|
runner=runner,
|
||||||
|
executor=ImmediateExecutor(),
|
||||||
|
on_event=event_list.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_success_uses_configured_cookie_and_atomically_builds_playlist(self):
|
||||||
|
events = []
|
||||||
|
self.cookie.chmod(0o444)
|
||||||
|
runner = FakeYtDlp(self.media, rewrite_cookie=True)
|
||||||
|
manager = self.manager(runner, events)
|
||||||
|
result = manager.enqueue("https://example.test/playlist", "Alice", "Service")
|
||||||
|
self.assertEqual(result.status, "reserved")
|
||||||
|
self.assertEqual([event.state for event in events], ["queued", "downloading", "ready"])
|
||||||
|
slot = self.store.get_slot(1)
|
||||||
|
self.assertEqual(slot.media_state, "ready")
|
||||||
|
self.assertEqual(slot.title, "Test Playlist")
|
||||||
|
self.assertEqual(slot.item_count, 2)
|
||||||
|
playlist = canonical_playlist_path(self.media, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
playlist.read_text(encoding="utf-8"),
|
||||||
|
"001 - First [one].mp4\n002 - Second [two].webm\n",
|
||||||
|
)
|
||||||
|
self.assertEqual(list(playlist.parent.glob(".playlist.*.tmp")), [])
|
||||||
|
for arguments, _ in runner.calls:
|
||||||
|
self.assertIn("--ignore-config", arguments)
|
||||||
|
self.assertIn("--no-js-runtimes", arguments)
|
||||||
|
self.assertEqual(arguments[arguments.index("--js-runtimes") + 1], "node")
|
||||||
|
self.assertEqual(
|
||||||
|
arguments[arguments.index("--cache-dir") + 1],
|
||||||
|
"/tmp/yt-dlp-cache",
|
||||||
|
)
|
||||||
|
self.assertIn("--cookies", arguments)
|
||||||
|
self.assertNotIn(str(self.cookie), arguments)
|
||||||
|
self.assertEqual(len(runner.cookie_observations), 2)
|
||||||
|
working_paths = [item[0] for item in runner.cookie_observations]
|
||||||
|
self.assertEqual(len(set(working_paths)), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
all(item[1] == "cookie data" for item in runner.cookie_observations)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(item[2] == 0o600 for item in runner.cookie_observations)
|
||||||
|
)
|
||||||
|
self.assertTrue(all(not path.exists() for path in working_paths))
|
||||||
|
self.assertEqual(self.cookie.read_text(encoding="utf-8"), "cookie data")
|
||||||
|
download_arguments = runner.calls[-1][0]
|
||||||
|
self.assertIn("bv*[height<=720]+ba/b[height<=720]/b", download_arguments)
|
||||||
|
|
||||||
|
def test_worker_failure_is_contained_and_persisted(self):
|
||||||
|
events = []
|
||||||
|
runner = FakeYtDlp(self.media, fail_download=True)
|
||||||
|
manager = self.manager(runner, events)
|
||||||
|
result = manager.enqueue("https://example.test/failure", None, None)
|
||||||
|
self.assertEqual(result.status, "reserved")
|
||||||
|
slot = self.store.get_slot(1)
|
||||||
|
self.assertEqual(slot.media_state, "error")
|
||||||
|
self.assertIn("exit code 7", slot.last_error)
|
||||||
|
self.assertEqual(events[-1].state, "error")
|
||||||
|
self.assertTrue(
|
||||||
|
all(not observation[0].exists() for observation in runner.cookie_observations)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_required_cookie_fails_job_without_escaping(self):
|
||||||
|
self.cookie.unlink()
|
||||||
|
events = []
|
||||||
|
runner = FakeYtDlp(self.media)
|
||||||
|
manager = self.manager(runner, events)
|
||||||
|
manager.enqueue("https://example.test/no-cookie", None, None)
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "error")
|
||||||
|
self.assertIn("required yt-dlp cookie", self.store.get_slot(1).last_error)
|
||||||
|
self.assertEqual(runner.calls, [])
|
||||||
|
|
||||||
|
def test_delete_moves_only_canonical_slot_and_keeps_other_identity(self):
|
||||||
|
first = self.store.reserve_download("https://example.test/one", None, None, job_id="one")
|
||||||
|
second = self.store.reserve_download("https://example.test/two", None, None, job_id="two")
|
||||||
|
self.store.fail_download("one", 1, first.generation, "failed")
|
||||||
|
self.store.fail_download("two", 2, second.generation, "failed")
|
||||||
|
slot_one = self.media / "slot1"
|
||||||
|
slot_two = self.media / "slot2"
|
||||||
|
slot_one.mkdir(parents=True)
|
||||||
|
slot_two.mkdir(parents=True)
|
||||||
|
(slot_one / "one.mp4").write_bytes(b"one")
|
||||||
|
(slot_two / "two.mp4").write_bytes(b"two")
|
||||||
|
manager = self.manager(FakeYtDlp(self.media))
|
||||||
|
self.assertTrue(manager.delete_slot(1, reason="manual"))
|
||||||
|
self.assertFalse(slot_one.exists())
|
||||||
|
self.assertTrue((slot_two / "two.mp4").exists())
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||||
|
self.assertEqual(self.store.get_slot(2).url, "https://example.test/two")
|
||||||
|
|
||||||
|
def test_delete_refuses_live_download_and_playback_guard(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/live", None, None, job_id="live"
|
||||||
|
)
|
||||||
|
manager = self.manager(FakeYtDlp(self.media))
|
||||||
|
with self.assertRaises(SlotProtectedError):
|
||||||
|
manager.delete_slot(1, reason="manual")
|
||||||
|
self.store.fail_download("live", 1, reservation.generation, "cancelled")
|
||||||
|
self.store.protect_slot(1, "player_active", guard_id="active")
|
||||||
|
with self.assertRaises(SlotProtectedError):
|
||||||
|
manager.delete_slot(1, reason="manual")
|
||||||
|
|
||||||
|
def test_symlink_slot_is_unlinked_without_touching_target(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/link", None, None, job_id="link"
|
||||||
|
)
|
||||||
|
self.store.fail_download("link", 1, reservation.generation, "failed")
|
||||||
|
target = Path(self.tempdir.name) / "outside"
|
||||||
|
target.mkdir()
|
||||||
|
protected = target / "keep.txt"
|
||||||
|
protected.write_text("keep", encoding="utf-8")
|
||||||
|
self.media.mkdir(parents=True)
|
||||||
|
os.symlink(target, self.media / "slot1")
|
||||||
|
manager = self.manager(FakeYtDlp(self.media))
|
||||||
|
self.assertTrue(manager.delete_slot(1, reason="manual"))
|
||||||
|
self.assertEqual(protected.read_text(encoding="utf-8"), "keep")
|
||||||
|
|
||||||
|
def test_interrupted_deletion_is_recoverable(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/recover", None, None, job_id="recover"
|
||||||
|
)
|
||||||
|
self.store.fail_download("recover", 1, reservation.generation, "failed")
|
||||||
|
token = self.store.begin_delete(1, reason="manual")
|
||||||
|
slot = self.media / "slot1"
|
||||||
|
slot.mkdir(parents=True)
|
||||||
|
(slot / "partial.mp4").write_bytes(b"partial")
|
||||||
|
self.assertEqual(self.store.pending_deletions(), [
|
||||||
|
type(token)(slot=1, generation=token.generation, reason="recovered_interrupted_deletion")
|
||||||
|
])
|
||||||
|
manager = self.manager(FakeYtDlp(self.media))
|
||||||
|
self.assertEqual(manager.recover_pending_deletions(), [1])
|
||||||
|
self.assertFalse(slot.exists())
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||||
|
|
||||||
|
def test_interrupted_deletion_recovery_defers_a_new_playback_guard(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/protected-recovery",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id="protected-recovery",
|
||||||
|
)
|
||||||
|
self.store.fail_download(
|
||||||
|
"protected-recovery",
|
||||||
|
1,
|
||||||
|
reservation.generation,
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
self.store.begin_delete(1, reason="manual")
|
||||||
|
slot = self.media / "slot1"
|
||||||
|
slot.mkdir(parents=True)
|
||||||
|
(slot / "still-playing.mp4").write_bytes(b"playing")
|
||||||
|
self.store.protect_slot(1, "player_active", guard_id="fresh-player")
|
||||||
|
manager = self.manager(FakeYtDlp(self.media))
|
||||||
|
|
||||||
|
self.assertEqual(manager.recover_pending_deletions(), [])
|
||||||
|
self.assertTrue(slot.exists())
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||||
|
|
||||||
|
self.store.clear_playback_guards(reason="fresh terminal idle evidence")
|
||||||
|
self.assertEqual(manager.recover_pending_deletions(), [1])
|
||||||
|
self.assertFalse(slot.exists())
|
||||||
|
|
||||||
|
def test_async_deletion_failure_remains_durable_and_is_retryable(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/retry-delete",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id="retry-delete",
|
||||||
|
)
|
||||||
|
self.store.fail_download(
|
||||||
|
"retry-delete",
|
||||||
|
1,
|
||||||
|
reservation.generation,
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
slot = self.media / "slot1"
|
||||||
|
slot.mkdir(parents=True)
|
||||||
|
(slot / "retry.mp4").write_bytes(b"retry")
|
||||||
|
events = []
|
||||||
|
manager = self.manager(FakeYtDlp(self.media), events)
|
||||||
|
original_recover = manager._recover_deletion
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
def fail_once(token):
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
raise OSError("transient media failure")
|
||||||
|
return original_recover(token)
|
||||||
|
|
||||||
|
manager._recover_deletion = fail_once
|
||||||
|
token = manager.schedule_delete_slot(1, reason="manual")
|
||||||
|
|
||||||
|
self.assertEqual(token.slot, 1)
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||||
|
self.assertEqual(events[-1].state, "delete_error")
|
||||||
|
self.assertEqual(len(self.store.pending_deletions()), 1)
|
||||||
|
|
||||||
|
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||||
|
self.assertEqual(attempts, 2)
|
||||||
|
self.assertEqual(events[-1].state, "deleted")
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||||
|
self.assertFalse(slot.exists())
|
||||||
|
|
||||||
|
def test_async_pending_recovery_retries_after_guard_is_cleared(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/deferred-async-delete",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id="deferred-async-delete",
|
||||||
|
)
|
||||||
|
self.store.fail_download(
|
||||||
|
"deferred-async-delete",
|
||||||
|
1,
|
||||||
|
reservation.generation,
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
self.store.begin_delete(1, reason="manual")
|
||||||
|
slot = self.media / "slot1"
|
||||||
|
slot.mkdir(parents=True)
|
||||||
|
(slot / "active.mp4").write_bytes(b"active")
|
||||||
|
self.store.protect_slot(1, "player_active", guard_id="active-player")
|
||||||
|
events = []
|
||||||
|
manager = self.manager(FakeYtDlp(self.media), events)
|
||||||
|
|
||||||
|
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||||
|
self.assertEqual(events[-1].state, "delete_deferred")
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||||
|
self.assertTrue(slot.exists())
|
||||||
|
|
||||||
|
self.store.clear_playback_guards(reason="fresh terminal idle evidence")
|
||||||
|
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||||
|
self.assertEqual(events[-1].state, "deleted")
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||||
|
self.assertFalse(slot.exists())
|
||||||
|
|
||||||
|
def test_guarded_recovery_restores_crash_staged_trash_to_canonical_slot(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/restore-trash",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id="restore-trash",
|
||||||
|
)
|
||||||
|
self.store.fail_download(
|
||||||
|
"restore-trash",
|
||||||
|
1,
|
||||||
|
reservation.generation,
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
token = self.store.begin_delete(1, reason="expired")
|
||||||
|
trash = (
|
||||||
|
self.media
|
||||||
|
/ ".controller-trash"
|
||||||
|
/ f"slot1.generation{token.generation}.crash"
|
||||||
|
)
|
||||||
|
trash.mkdir(parents=True)
|
||||||
|
(trash / "still-active.mp4").write_bytes(b"active")
|
||||||
|
self.store.protect_slot(1, "player_active", guard_id="fresh-active")
|
||||||
|
events = []
|
||||||
|
manager = self.manager(FakeYtDlp(self.media), events)
|
||||||
|
|
||||||
|
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||||
|
|
||||||
|
canonical = self.media / "slot1"
|
||||||
|
self.assertEqual(events[-1].state, "delete_deferred")
|
||||||
|
self.assertTrue((canonical / "still-active.mp4").exists())
|
||||||
|
self.assertFalse(trash.exists())
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||||
|
|
||||||
|
def test_queued_deletion_rechecks_guard_before_filesystem_mutation(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/queued-delete-guard",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id="queued-delete-guard",
|
||||||
|
)
|
||||||
|
self.store.fail_download(
|
||||||
|
"queued-delete-guard",
|
||||||
|
1,
|
||||||
|
reservation.generation,
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
self.store.begin_delete(1, reason="manual")
|
||||||
|
slot = self.media / "slot1"
|
||||||
|
slot.mkdir(parents=True)
|
||||||
|
(slot / "must-survive.mp4").write_bytes(b"active")
|
||||||
|
events = []
|
||||||
|
executor = QueuedExecutor()
|
||||||
|
manager = DownloadManager(
|
||||||
|
self.config,
|
||||||
|
self.store,
|
||||||
|
runner=FakeYtDlp(self.media),
|
||||||
|
executor=executor,
|
||||||
|
on_event=events.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||||
|
self.store.protect_slot(1, "player_active", guard_id="late-player")
|
||||||
|
executor.run_next()
|
||||||
|
|
||||||
|
self.assertEqual(events[-1].state, "delete_deferred")
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||||
|
self.assertTrue((slot / "must-survive.mp4").exists())
|
||||||
|
|
||||||
|
self.store.clear_playback_guards(reason="fresh terminal idle evidence")
|
||||||
|
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||||
|
executor.run_next()
|
||||||
|
self.assertEqual(events[-1].state, "deleted")
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||||
|
self.assertFalse(slot.exists())
|
||||||
|
|
||||||
|
def test_shutdown_terminates_tracked_process_without_waiting_for_job_timeout(self):
|
||||||
|
manager = DownloadManager(self.config, self.store)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def run_long_process():
|
||||||
|
try:
|
||||||
|
manager._run_command(
|
||||||
|
["/bin/sh", "-c", "sleep 60"],
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=run_long_process)
|
||||||
|
worker.start()
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
with manager._lock:
|
||||||
|
if manager._processes:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("test process did not start")
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
manager.shutdown(wait=False)
|
||||||
|
worker.join(timeout=3)
|
||||||
|
self.assertFalse(worker.is_alive())
|
||||||
|
self.assertLess(time.monotonic() - started, 3)
|
||||||
|
self.assertTrue(errors)
|
||||||
|
with manager._lock:
|
||||||
|
self.assertEqual(manager._processes, set())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from controller.config import ControllerConfig, MqttConfig
|
||||||
|
from controller.mqtt_adapter import MqttAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class MutableClock:
|
||||||
|
def __init__(self, value=0.0):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMessageInfo:
|
||||||
|
def __init__(self, mid, published):
|
||||||
|
self.rc = mqtt.MQTT_ERR_SUCCESS
|
||||||
|
self.mid = mid
|
||||||
|
self.published = published
|
||||||
|
self.waited = []
|
||||||
|
|
||||||
|
def wait_for_publish(self, timeout=None):
|
||||||
|
self.waited.append(timeout)
|
||||||
|
|
||||||
|
def is_published(self):
|
||||||
|
return self.published
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.published = []
|
||||||
|
self.subscriptions = []
|
||||||
|
self.acks = []
|
||||||
|
self.suppress_exceptions = False
|
||||||
|
self.connect_error = None
|
||||||
|
self.connect_result = None
|
||||||
|
self.loop_result = mqtt.MQTT_ERR_SUCCESS
|
||||||
|
self.publish_acknowledged = None
|
||||||
|
self.publish_hook = None
|
||||||
|
|
||||||
|
def username_pw_set(self, username, password):
|
||||||
|
self.calls.append(("username_pw_set", username, password))
|
||||||
|
|
||||||
|
def manual_ack_set(self, enabled):
|
||||||
|
self.calls.append(("manual_ack_set", enabled))
|
||||||
|
|
||||||
|
def max_queued_messages_set(self, count):
|
||||||
|
self.calls.append(("max_queued_messages_set", count))
|
||||||
|
|
||||||
|
def reconnect_delay_set(self, **kwargs):
|
||||||
|
self.calls.append(("reconnect_delay_set", kwargs))
|
||||||
|
|
||||||
|
def connect_async(self, *args, **kwargs):
|
||||||
|
self.calls.append(("connect_async", args, kwargs))
|
||||||
|
if self.connect_error is not None:
|
||||||
|
raise self.connect_error
|
||||||
|
return self.connect_result
|
||||||
|
|
||||||
|
def connect(self, *args, **kwargs):
|
||||||
|
self.calls.append(("connect", args, kwargs))
|
||||||
|
raise AssertionError("synchronous connect must never be used")
|
||||||
|
|
||||||
|
def loop_start(self):
|
||||||
|
self.calls.append(("loop_start",))
|
||||||
|
return self.loop_result
|
||||||
|
|
||||||
|
def loop_stop(self):
|
||||||
|
self.calls.append(("loop_stop",))
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.calls.append(("disconnect",))
|
||||||
|
|
||||||
|
def subscribe(self, topic, qos=0):
|
||||||
|
self.subscriptions.append((topic, qos))
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS, len(self.subscriptions)
|
||||||
|
|
||||||
|
def publish(self, topic, payload, qos=0, retain=False):
|
||||||
|
self.published.append((topic, payload, qos, retain))
|
||||||
|
if self.publish_hook is not None:
|
||||||
|
self.publish_hook(topic, payload, qos, retain)
|
||||||
|
if self.publish_acknowledged is not None:
|
||||||
|
return FakeMessageInfo(
|
||||||
|
len(self.published), self.publish_acknowledged
|
||||||
|
)
|
||||||
|
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=len(self.published))
|
||||||
|
|
||||||
|
def ack(self, message_id, qos):
|
||||||
|
self.acks.append((message_id, qos))
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
class MqttAdapterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = FakeClient()
|
||||||
|
self.events = queue.Queue()
|
||||||
|
self.config = ControllerConfig(
|
||||||
|
mqtt=MqttConfig(
|
||||||
|
host="broker",
|
||||||
|
port=1884,
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
client_id="controller-test",
|
||||||
|
reconnect_min_seconds=1,
|
||||||
|
reconnect_max_seconds=2,
|
||||||
|
command_queue_size=8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.adapter = MqttAdapter(
|
||||||
|
self.config,
|
||||||
|
client=self.client,
|
||||||
|
events=self.events,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.adapter.stop()
|
||||||
|
|
||||||
|
def test_start_is_asynchronous_and_configures_manual_ack(self):
|
||||||
|
self.adapter.start()
|
||||||
|
names = [call[0] for call in self.client.calls]
|
||||||
|
self.assertIn("manual_ack_set", names)
|
||||||
|
self.assertIn("connect_async", names)
|
||||||
|
self.assertIn("loop_start", names)
|
||||||
|
self.assertNotIn("connect", names)
|
||||||
|
connect = next(call for call in self.client.calls if call[0] == "connect_async")
|
||||||
|
self.assertEqual(connect[1], ("broker", 1884))
|
||||||
|
self.assertEqual(connect[2], {"keepalive": 30})
|
||||||
|
|
||||||
|
def test_paho_two_connect_async_none_starts_network_loop(self):
|
||||||
|
self.client.connect_result = None
|
||||||
|
self.adapter.start()
|
||||||
|
|
||||||
|
self.assertTrue(self.adapter.started)
|
||||||
|
self.assertIn("loop_start", [call[0] for call in self.client.calls])
|
||||||
|
self.assertIsNone(self.adapter.last_error)
|
||||||
|
|
||||||
|
def test_initial_setup_oserror_does_not_escape_startup(self):
|
||||||
|
self.client.connect_error = OSError("network unreachable")
|
||||||
|
self.adapter.start()
|
||||||
|
self.assertTrue(self.adapter.started)
|
||||||
|
self.assertIn("network unreachable", self.adapter.last_error)
|
||||||
|
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
|
||||||
|
|
||||||
|
def test_loop_start_failure_does_not_escape_startup(self):
|
||||||
|
self.client.loop_result = mqtt.MQTT_ERR_UNKNOWN
|
||||||
|
self.adapter.start()
|
||||||
|
self.assertTrue(self.adapter.started)
|
||||||
|
self.assertIn("failed to start MQTT network loop", self.adapter.last_error)
|
||||||
|
|
||||||
|
def test_stop_during_blocked_connect_never_starts_network_loop_afterward(self):
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
original_connect = self.client.connect_async
|
||||||
|
|
||||||
|
def blocked_connect(*args, **kwargs):
|
||||||
|
entered.set()
|
||||||
|
release.wait(timeout=2)
|
||||||
|
return original_connect(*args, **kwargs)
|
||||||
|
|
||||||
|
self.client.connect_async = blocked_connect
|
||||||
|
starter = threading.Thread(target=self.adapter.start)
|
||||||
|
starter.start()
|
||||||
|
self.assertTrue(entered.wait(timeout=1))
|
||||||
|
self.adapter.stop()
|
||||||
|
release.set()
|
||||||
|
starter.join(timeout=2)
|
||||||
|
self.assertFalse(starter.is_alive())
|
||||||
|
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
|
||||||
|
self.assertFalse(self.adapter.started)
|
||||||
|
|
||||||
|
def test_stop_wins_against_connect_callback_blocked_during_subscribe(self):
|
||||||
|
self.adapter.start()
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
original_subscribe = self.client.subscribe
|
||||||
|
|
||||||
|
def blocked_subscribe(topic, qos=0):
|
||||||
|
entered.set()
|
||||||
|
release.wait(timeout=2)
|
||||||
|
return original_subscribe(topic, qos=qos)
|
||||||
|
|
||||||
|
self.client.subscribe = blocked_subscribe
|
||||||
|
callback = threading.Thread(
|
||||||
|
target=self.adapter._on_connect,
|
||||||
|
args=(self.client, None, None, 0),
|
||||||
|
)
|
||||||
|
callback.start()
|
||||||
|
self.assertTrue(entered.wait(timeout=1))
|
||||||
|
stopper = threading.Thread(target=self.adapter.stop)
|
||||||
|
stopper.start()
|
||||||
|
release.set()
|
||||||
|
callback.join(timeout=2)
|
||||||
|
stopper.join(timeout=2)
|
||||||
|
self.assertFalse(callback.is_alive())
|
||||||
|
self.assertFalse(stopper.is_alive())
|
||||||
|
self.assertFalse(self.adapter.started)
|
||||||
|
self.assertFalse(self.adapter.connected)
|
||||||
|
self.assertLess(len(self.client.subscriptions), 6)
|
||||||
|
self.assertTrue(self.events.empty())
|
||||||
|
self.assertFalse(
|
||||||
|
self.adapter.publish_player_command({"action": "stop"}).accepted
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_connect_subscribes_to_all_authoritative_inputs(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.assertTrue(self.adapter.connected)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.subscriptions,
|
||||||
|
[
|
||||||
|
("youtube/player/status", 1),
|
||||||
|
("youtube/player/telemetry", 0),
|
||||||
|
("youtube/player/state", 0),
|
||||||
|
("youtube/controller/cmd", 1),
|
||||||
|
("audio/player/mode/set", 1),
|
||||||
|
("audio/player/mode/state", 1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
event = self.events.get_nowait()
|
||||||
|
self.assertEqual(event.kind, "connected")
|
||||||
|
self.assertEqual(event.connection_generation, 1)
|
||||||
|
|
||||||
|
def test_player_commands_are_exact_qos_one_and_nonretained(self):
|
||||||
|
disconnected = self.adapter.publish_player_command({"action": "stop"})
|
||||||
|
self.assertFalse(disconnected.accepted)
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
receipt = self.adapter.publish_player_command(
|
||||||
|
{"action": "play", "slot": 3, "label": "Opening"}
|
||||||
|
)
|
||||||
|
self.assertTrue(receipt.accepted)
|
||||||
|
topic, payload, qos, retain = self.client.published[-1]
|
||||||
|
self.assertEqual(topic, "youtube/player/cmd")
|
||||||
|
self.assertEqual(json.loads(payload), {"action": "play", "slot": 3, "label": "Opening"})
|
||||||
|
self.assertEqual(qos, 1)
|
||||||
|
self.assertIs(retain, False)
|
||||||
|
|
||||||
|
def test_router_commands_are_qos_one_nonretained_plain_strings(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
receipt = self.adapter.publish_router_mode("video")
|
||||||
|
self.assertTrue(receipt.accepted)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.published[-1],
|
||||||
|
("audio/player/mode/set", "video", 1, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_router_self_echo_is_marked_while_publish_is_in_flight(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
|
||||||
|
def echo(topic, payload, qos, retain):
|
||||||
|
self.adapter._on_message(
|
||||||
|
self.client,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(
|
||||||
|
topic=topic,
|
||||||
|
payload=payload.encode("utf-8"),
|
||||||
|
qos=qos,
|
||||||
|
retain=retain,
|
||||||
|
dup=False,
|
||||||
|
mid=31,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.publish_hook = echo
|
||||||
|
self.assertTrue(self.adapter.publish_router_mode("video").accepted)
|
||||||
|
self.assertTrue(self.events.get_nowait().controller_echo)
|
||||||
|
|
||||||
|
def test_router_self_echo_delivered_after_puback_is_still_claimed_within_grace(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
# Regression: a broker may legally deliver our own subscription echo
|
||||||
|
# after the PUBACK that already acknowledged the same publish. If the
|
||||||
|
# echo marker were cleared the instant publish() observes that
|
||||||
|
# PUBACK, this late echo would be mistaken for live operator intent
|
||||||
|
# and cause automatic Router restoration ownership to be relinquished.
|
||||||
|
clock = MutableClock(1000.0)
|
||||||
|
client = FakeClient()
|
||||||
|
events = queue.Queue()
|
||||||
|
adapter = MqttAdapter(self.config, client=client, events=events, monotonic=clock)
|
||||||
|
try:
|
||||||
|
adapter.start()
|
||||||
|
adapter._on_connect(client, None, None, 0)
|
||||||
|
events.get_nowait()
|
||||||
|
|
||||||
|
self.assertTrue(adapter.publish_router_mode("video").accepted)
|
||||||
|
clock.value += self.config.mqtt.router_echo_grace_seconds - 0.5
|
||||||
|
|
||||||
|
adapter._on_message(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(
|
||||||
|
topic="audio/player/mode/set",
|
||||||
|
payload=b"video",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
dup=False,
|
||||||
|
mid=32,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(events.get_nowait().controller_echo)
|
||||||
|
finally:
|
||||||
|
adapter.stop()
|
||||||
|
|
||||||
|
def test_router_self_echo_marker_expires_after_grace_window(self):
|
||||||
|
# A genuine external/operator command that happens to repeat the same
|
||||||
|
# value must still be recognized as operator intent once the bounded
|
||||||
|
# grace window has elapsed; the marker must not linger forever.
|
||||||
|
clock = MutableClock(1000.0)
|
||||||
|
client = FakeClient()
|
||||||
|
events = queue.Queue()
|
||||||
|
adapter = MqttAdapter(self.config, client=client, events=events, monotonic=clock)
|
||||||
|
try:
|
||||||
|
adapter.start()
|
||||||
|
adapter._on_connect(client, None, None, 0)
|
||||||
|
events.get_nowait()
|
||||||
|
|
||||||
|
self.assertTrue(adapter.publish_router_mode("video").accepted)
|
||||||
|
clock.value += self.config.mqtt.router_echo_grace_seconds + 0.5
|
||||||
|
|
||||||
|
adapter._on_message(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(
|
||||||
|
topic="audio/player/mode/set",
|
||||||
|
payload=b"video",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
dup=False,
|
||||||
|
mid=32,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(events.get_nowait().controller_echo)
|
||||||
|
finally:
|
||||||
|
adapter.stop()
|
||||||
|
|
||||||
|
def test_retained_router_set_never_consumes_controller_echo_marker(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
|
||||||
|
def retained_then_live_echo(topic, payload, qos, retain):
|
||||||
|
for message_retain, mid in ((True, 41), (False, 42)):
|
||||||
|
self.adapter._on_message(
|
||||||
|
self.client,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(
|
||||||
|
topic=topic,
|
||||||
|
payload=payload.encode("utf-8"),
|
||||||
|
qos=qos,
|
||||||
|
retain=message_retain,
|
||||||
|
dup=False,
|
||||||
|
mid=mid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.publish_hook = retained_then_live_echo
|
||||||
|
self.assertTrue(self.adapter.publish_router_mode("video").accepted)
|
||||||
|
retained_event = self.events.get_nowait()
|
||||||
|
live_event = self.events.get_nowait()
|
||||||
|
self.assertTrue(retained_event.retain)
|
||||||
|
self.assertFalse(retained_event.controller_echo)
|
||||||
|
self.assertFalse(live_event.retain)
|
||||||
|
self.assertTrue(live_event.controller_echo)
|
||||||
|
|
||||||
|
def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
message = SimpleNamespace(
|
||||||
|
topic="youtube/controller/cmd",
|
||||||
|
payload=b'{"action":"stop"}',
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
mid=42,
|
||||||
|
)
|
||||||
|
self.adapter._on_message(self.client, None, message)
|
||||||
|
event = self.events.get_nowait()
|
||||||
|
self.assertEqual(self.client.acks, [])
|
||||||
|
self.assertEqual(event.message_id, 42)
|
||||||
|
self.assertTrue(self.adapter.acknowledge(event))
|
||||||
|
self.assertEqual(self.client.acks, [(42, 1)])
|
||||||
|
|
||||||
|
def test_stale_mid_from_previous_connection_is_not_acked(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
message = SimpleNamespace(
|
||||||
|
topic="youtube/controller/cmd",
|
||||||
|
payload=b'{"action":"stop"}',
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
mid=7,
|
||||||
|
)
|
||||||
|
self.adapter._on_message(self.client, None, message)
|
||||||
|
old_event = self.events.get_nowait()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
self.assertFalse(self.adapter.acknowledge(old_event))
|
||||||
|
self.assertEqual(self.client.acks, [])
|
||||||
|
|
||||||
|
def test_retained_flag_is_preserved_for_coordinator_rejection(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
message = SimpleNamespace(
|
||||||
|
topic="youtube/controller/cmd",
|
||||||
|
payload=b'{"action":"delete_slot","slot":1}',
|
||||||
|
qos=1,
|
||||||
|
retain=True,
|
||||||
|
mid=8,
|
||||||
|
)
|
||||||
|
self.adapter._on_message(self.client, None, message)
|
||||||
|
self.assertIs(self.events.get_nowait().retain, True)
|
||||||
|
|
||||||
|
def test_duplicate_flag_is_preserved_for_coordinator_deduplication(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
message = SimpleNamespace(
|
||||||
|
topic="youtube/controller/cmd",
|
||||||
|
payload=b'{"action":"toggle"}',
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
dup=True,
|
||||||
|
mid=9,
|
||||||
|
)
|
||||||
|
self.adapter._on_message(self.client, None, message)
|
||||||
|
self.assertIs(self.events.get_nowait().duplicate, True)
|
||||||
|
|
||||||
|
def test_missing_puback_is_uncertain_and_invalidates_connection(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
self.client.publish_acknowledged = False
|
||||||
|
receipt = self.adapter.publish_player_command({"action": "stop"})
|
||||||
|
self.assertFalse(receipt.accepted)
|
||||||
|
self.assertTrue(receipt.uncertain)
|
||||||
|
self.assertIn("no broker PUBACK", receipt.reason)
|
||||||
|
self.assertFalse(self.adapter.connected)
|
||||||
|
|
||||||
|
def test_optional_qos_event_puback_failure_does_not_invalidate_transport(self):
|
||||||
|
self.adapter.start()
|
||||||
|
self.adapter._on_connect(self.client, None, None, 0)
|
||||||
|
self.events.get_nowait()
|
||||||
|
self.client.publish_acknowledged = False
|
||||||
|
receipt = self.adapter.publish_json(
|
||||||
|
"youtube/controller/v2/events",
|
||||||
|
{"version": 2, "type": "warning"},
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(receipt.accepted)
|
||||||
|
self.assertTrue(receipt.uncertain)
|
||||||
|
self.assertTrue(self.adapter.connected)
|
||||||
|
|
||||||
|
def test_discarded_transport_releases_callback_blocked_on_full_queue(self):
|
||||||
|
events = queue.Queue(maxsize=1)
|
||||||
|
client = FakeClient()
|
||||||
|
adapter = MqttAdapter(self.config, client=client, events=events)
|
||||||
|
try:
|
||||||
|
adapter.start()
|
||||||
|
adapter._on_connect(client, None, None, 0)
|
||||||
|
self.assertTrue(events.full())
|
||||||
|
callback = threading.Thread(
|
||||||
|
target=adapter._on_message,
|
||||||
|
args=(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(
|
||||||
|
topic="youtube/controller/cmd",
|
||||||
|
payload=b'{"action":"stop"}',
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
dup=False,
|
||||||
|
mid=99,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
callback.start()
|
||||||
|
callback.join(timeout=0.05)
|
||||||
|
self.assertTrue(callback.is_alive())
|
||||||
|
|
||||||
|
adapter._discard_unacknowledged_transport("test transport discard")
|
||||||
|
callback.join(timeout=1)
|
||||||
|
self.assertFalse(callback.is_alive())
|
||||||
|
self.assertEqual(events.qsize(), 1)
|
||||||
|
self.assertEqual(events.get_nowait().kind, "connected")
|
||||||
|
finally:
|
||||||
|
adapter.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from controller.config import ControllerConfig, StorageConfig
|
||||||
|
from controller.playback import OperationInProgressError, PlaybackReducer
|
||||||
|
from controller.store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackReducerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
base = Path(self.tempdir.name)
|
||||||
|
self.wall = Clock(1_700_000_000.0)
|
||||||
|
self.monotonic = Clock(100.0)
|
||||||
|
self.config = ControllerConfig(
|
||||||
|
storage=StorageConfig(
|
||||||
|
media_base=base / "media",
|
||||||
|
data_base=base / "data",
|
||||||
|
retention_seconds=100,
|
||||||
|
uncertain_review_after_seconds=30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.store = ControllerStore(self.config.storage, now=self.wall).open()
|
||||||
|
self.reducer = PlaybackReducer(
|
||||||
|
self.config,
|
||||||
|
self.store,
|
||||||
|
monotonic=self.monotonic,
|
||||||
|
wall_clock=self.wall,
|
||||||
|
operation_timeout_seconds=5,
|
||||||
|
)
|
||||||
|
self._ready(1, "one")
|
||||||
|
self._ready(2, "two")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
|
||||||
|
def _ready(self, slot, suffix):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
f"https://example.test/{suffix}", None, None, job_id=f"job-{suffix}"
|
||||||
|
)
|
||||||
|
self.assertEqual(reservation.slot, slot)
|
||||||
|
self.store.mark_download_running(
|
||||||
|
reservation.job_id, reservation.slot, reservation.generation
|
||||||
|
)
|
||||||
|
self.store.complete_download(
|
||||||
|
reservation.job_id,
|
||||||
|
reservation.slot,
|
||||||
|
reservation.generation,
|
||||||
|
title=suffix.title(),
|
||||||
|
items=((suffix.title(), f"{suffix}.mp4"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_play_intent_is_not_optimistically_reported_playing(self):
|
||||||
|
operation = self.reducer.begin_operation(
|
||||||
|
"play",
|
||||||
|
slot=1,
|
||||||
|
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
)
|
||||||
|
self.assertEqual(operation.state, "awaiting_player")
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "unknown")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "ready")
|
||||||
|
with self.assertRaises(OperationInProgressError):
|
||||||
|
self.reducer.begin_operation("stop")
|
||||||
|
|
||||||
|
def test_matching_player_playing_confirms_play_and_protects_slot(self):
|
||||||
|
self.reducer.begin_operation(
|
||||||
|
"play",
|
||||||
|
slot=1,
|
||||||
|
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 1,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
|
||||||
|
self.assertIsNone(self.reducer.pending)
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "playing")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||||
|
guards = self.store.list_live_guards()
|
||||||
|
self.assertEqual([(guard.slot, guard.kind) for guard in guards], [(1, "player_active")])
|
||||||
|
|
||||||
|
def test_reconnect_online_idle_with_path_is_not_terminal(self):
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 1,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "idle",
|
||||||
|
"slot": 1,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
"message": "player online",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(effects[0].kind, "player_reconnected_active")
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "playing")
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
|
||||||
|
|
||||||
|
def test_reconnect_online_idle_without_path_is_fresh_idle_evidence(self):
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 1,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.reducer.handle_status({"state": "idle", "message": "player online"})
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "idle")
|
||||||
|
self.assertEqual(self.store.list_live_guards(), [])
|
||||||
|
|
||||||
|
def test_offline_without_slot_preserves_active_protection_as_unknown(self):
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 1,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status({"state": "offline"})
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(effects[0].kind, "player_offline")
|
||||||
|
self.assertEqual(observation.active_slot, 1)
|
||||||
|
self.assertEqual(observation.phase, "unknown")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].kind, "restart_uncertain")
|
||||||
|
|
||||||
|
def test_busy_identifies_existing_playback_not_requested_slot(self):
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 1,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.reducer.begin_operation(
|
||||||
|
"play",
|
||||||
|
slot=2,
|
||||||
|
expected_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "busy",
|
||||||
|
"message": "mpv already playing; stop first",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIn("operation_failed", [effect.kind for effect in effects])
|
||||||
|
self.assertEqual(self.store.player_observation().active_slot, 1)
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(2), "ready")
|
||||||
|
|
||||||
|
def test_real_pathless_busy_without_prior_identity_becomes_unknown(self):
|
||||||
|
self.reducer.begin_operation(
|
||||||
|
"play",
|
||||||
|
slot=2,
|
||||||
|
expected_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "busy",
|
||||||
|
"message": "mpv already playing; stop first",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("operation_failed", [effect.kind for effect in effects])
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.phase, "unknown")
|
||||||
|
self.assertIsNone(observation.active_slot)
|
||||||
|
self.assertIsNone(observation.active_path)
|
||||||
|
|
||||||
|
def test_pause_status_does_not_override_actual_telemetry(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
|
||||||
|
)
|
||||||
|
self.reducer.begin_operation("pause", slot=1, expected_path=path)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{"state": "paused", "slot": 1, "path": path}
|
||||||
|
)
|
||||||
|
self.assertIn("control_issued", [effect.kind for effect in effects])
|
||||||
|
self.assertIsNotNone(self.reducer.pending)
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||||
|
effects = self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": path, "paused": True}
|
||||||
|
)
|
||||||
|
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "paused")
|
||||||
|
|
||||||
|
def test_terminal_idle_and_cleared_telemetry_release_protection(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
|
||||||
|
)
|
||||||
|
self.reducer.begin_operation("stop", slot=1, expected_path=path)
|
||||||
|
effects = self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": None, "paused": False}
|
||||||
|
)
|
||||||
|
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "idle")
|
||||||
|
self.assertEqual(self.store.list_live_guards(), [])
|
||||||
|
self.reducer.handle_status({"state": "idle", "slot": 1, "reason": "stop"})
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "ready")
|
||||||
|
|
||||||
|
def test_error_does_not_release_owned_playback(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{"state": "error", "slot": 1, "path": path, "message": "cannot stop"}
|
||||||
|
)
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "error")
|
||||||
|
guard = self.store.list_live_guards()[0]
|
||||||
|
self.assertEqual(guard.slot, 1)
|
||||||
|
self.assertEqual(guard.kind, "restart_uncertain")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||||
|
|
||||||
|
def test_pathless_error_converts_previous_active_slot_to_reviewable_unknown(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{"state": "error", "message": "nothing playing to pause"}
|
||||||
|
)
|
||||||
|
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
guard = self.store.list_live_guards()[0]
|
||||||
|
self.assertEqual(observation.active_slot, 1)
|
||||||
|
self.assertEqual(observation.reconciliation_id, guard.guard_id)
|
||||||
|
self.assertEqual(observation.reconciliation_state, "awaiting_evidence")
|
||||||
|
self.assertEqual(guard.kind, "restart_uncertain")
|
||||||
|
|
||||||
|
def test_terminal_startup_error_clears_identity_even_if_clear_telemetry_was_lost(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.begin_operation("play", slot=1, expected_path=path)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "error",
|
||||||
|
"slot": 1,
|
||||||
|
"path": path,
|
||||||
|
"reason": "startup_error",
|
||||||
|
"message": "failed to start mpv",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("player_idle", [effect.kind for effect in effects])
|
||||||
|
self.assertIn("operation_failed", [effect.kind for effect in effects])
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.phase, "idle")
|
||||||
|
self.assertIsNone(observation.active_slot)
|
||||||
|
self.assertIsNone(observation.active_path)
|
||||||
|
self.assertEqual(self.store.list_live_guards(), [])
|
||||||
|
|
||||||
|
def test_terminal_worker_error_after_cleared_telemetry_remains_idle(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.begin_operation("play", slot=1, expected_path=path)
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": None, "paused": False}
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "error",
|
||||||
|
"slot": 1,
|
||||||
|
"path": path,
|
||||||
|
"reason": "worker_start_error",
|
||||||
|
"message": "failed to start playback worker",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("player_idle", [effect.kind for effect in effects])
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.phase, "idle")
|
||||||
|
self.assertIsNone(observation.active_path)
|
||||||
|
self.assertEqual(self.store.list_live_guards(), [])
|
||||||
|
|
||||||
|
def test_player_state_takes_precedence_over_deferred_storage_deletion(self):
|
||||||
|
reservation = self.store.reserve_download(
|
||||||
|
"https://example.test/late-player",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id="late-player",
|
||||||
|
)
|
||||||
|
self.store.fail_download(
|
||||||
|
"late-player",
|
||||||
|
1,
|
||||||
|
reservation.generation,
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
self.store.begin_delete(1, reason="expired")
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{"state": "playing", "slot": 1, "path": path}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||||
|
|
||||||
|
def test_play_timeout_becomes_unknown_then_operator_review_without_unprotecting(self):
|
||||||
|
self.reducer.begin_operation(
|
||||||
|
"play",
|
||||||
|
slot=1,
|
||||||
|
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
)
|
||||||
|
self.monotonic.value += 6
|
||||||
|
effect = self.reducer.check_timeout()
|
||||||
|
self.assertEqual(effect.kind, "operation_timed_out")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||||
|
self.wall.value += 31
|
||||||
|
review = self.reducer.mark_due_reconciliation()
|
||||||
|
self.assertEqual(review[0].kind, "operator_review")
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "operator_review")
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].state, "review_required")
|
||||||
|
|
||||||
|
def test_pause_timeout_invalidates_stale_playing_projection(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
|
||||||
|
)
|
||||||
|
self.reducer.begin_operation("pause", slot=1, expected_path=path)
|
||||||
|
self.monotonic.value += 6
|
||||||
|
|
||||||
|
effect = self.reducer.check_timeout()
|
||||||
|
|
||||||
|
self.assertEqual(effect.kind, "operation_timed_out")
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.phase, "unknown")
|
||||||
|
self.assertIsNone(observation.paused)
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].kind, "player_active")
|
||||||
|
|
||||||
|
def test_resume_timeout_invalidates_stale_paused_projection(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": 1, "path": path, "paused": True}
|
||||||
|
)
|
||||||
|
self.reducer.begin_operation("resume", slot=1, expected_path=path)
|
||||||
|
self.monotonic.value += 6
|
||||||
|
|
||||||
|
effect = self.reducer.check_timeout()
|
||||||
|
|
||||||
|
self.assertEqual(effect.kind, "operation_timed_out")
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.phase, "unknown")
|
||||||
|
self.assertIsNone(observation.paused)
|
||||||
|
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||||
|
|
||||||
|
def test_malformed_and_unknown_statuses_are_isolated(self):
|
||||||
|
for payload in (None, [], {}, {"state": "mystery"}):
|
||||||
|
effects = self.reducer.handle_status(payload)
|
||||||
|
self.assertEqual(effects[0].kind, "warning")
|
||||||
|
|
||||||
|
def test_contradictory_slot_and_path_cannot_confirm_play(self):
|
||||||
|
expected = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
|
||||||
|
self.reducer.begin_operation("play", slot=1, expected_path=expected)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 1,
|
||||||
|
"path": str(
|
||||||
|
self.config.storage.media_base / "slot2" / "playlist.m3u"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(effects[0].kind, "warning")
|
||||||
|
self.assertIsNotNone(self.reducer.pending)
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "unknown")
|
||||||
|
|
||||||
|
def test_mismatched_active_evidence_fails_intent_and_is_not_overwritten(self):
|
||||||
|
self.reducer.begin_operation(
|
||||||
|
"play",
|
||||||
|
slot=1,
|
||||||
|
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
)
|
||||||
|
effects = self.reducer.handle_status(
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"slot": 2,
|
||||||
|
"path": "/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIn("operation_failed", [effect.kind for effect in effects])
|
||||||
|
self.assertIsNone(self.reducer.pending)
|
||||||
|
self.monotonic.value += 100
|
||||||
|
self.assertIsNone(self.reducer.check_timeout())
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.active_slot, 2)
|
||||||
|
self.assertEqual(observation.phase, "playing")
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].slot, 2)
|
||||||
|
|
||||||
|
def test_bare_idle_does_not_clear_live_playback(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||||
|
effects = self.reducer.handle_status({"state": "idle", "slot": 1})
|
||||||
|
self.assertEqual(effects[0].kind, "warning")
|
||||||
|
self.assertEqual(self.store.player_observation().phase, "playing")
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
|
||||||
|
|
||||||
|
def test_path_only_evidence_derives_and_protects_managed_slot(self):
|
||||||
|
path = str(self.config.storage.media_base / "slot2" / "playlist.m3u")
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{"state": "telemetry", "slot": None, "path": path, "paused": False}
|
||||||
|
)
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.active_slot, 2)
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].slot, 2)
|
||||||
|
|
||||||
|
def test_duplicate_offline_evidence_does_not_postpone_review(self):
|
||||||
|
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||||
|
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||||
|
self.reducer.handle_status({"state": "offline"})
|
||||||
|
first_deadline = self.store.list_live_guards()[0].review_after
|
||||||
|
self.wall.value += 20
|
||||||
|
self.reducer.handle_status({"state": "offline"})
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.list_live_guards()[0].review_after,
|
||||||
|
first_deadline,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repeated_active_telemetry_reuses_guard_without_history_churn(self):
|
||||||
|
path = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
|
||||||
|
payload = {
|
||||||
|
"state": "telemetry",
|
||||||
|
"slot": 1,
|
||||||
|
"path": path,
|
||||||
|
"paused": False,
|
||||||
|
}
|
||||||
|
self.reducer.handle_telemetry(payload)
|
||||||
|
first_guard = self.store.list_live_guards()[0]
|
||||||
|
for _ in range(20):
|
||||||
|
self.reducer.handle_telemetry(payload)
|
||||||
|
self.assertEqual(self.store.list_live_guards()[0].guard_id, first_guard.guard_id)
|
||||||
|
count = self.store.connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM slot_guards"
|
||||||
|
).fetchone()[0]
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
|
def test_external_play_timeout_transitions_to_review_without_assuming_stop(self):
|
||||||
|
path = "/srv/operator-playlists/special-event.m3u"
|
||||||
|
self.reducer.begin_operation("play", expected_path=path)
|
||||||
|
self.monotonic.value += 6
|
||||||
|
timeout = self.reducer.check_timeout()
|
||||||
|
self.assertEqual(timeout.kind, "operation_timed_out")
|
||||||
|
uncertain = self.store.player_observation()
|
||||||
|
self.assertEqual(uncertain.active_path, path)
|
||||||
|
self.assertEqual(uncertain.reconciliation_state, "awaiting_evidence")
|
||||||
|
self.assertIsNotNone(uncertain.reconciliation_id)
|
||||||
|
|
||||||
|
self.wall.value += 31
|
||||||
|
effects = self.reducer.mark_due_reconciliation()
|
||||||
|
self.assertEqual(len(effects), 1)
|
||||||
|
self.assertEqual(effects[0].kind, "operator_review")
|
||||||
|
self.assertEqual(effects[0].operation_id, uncertain.reconciliation_id)
|
||||||
|
review = self.store.player_observation()
|
||||||
|
self.assertEqual(review.reconciliation_state, "operator_review")
|
||||||
|
self.assertEqual(review.active_path, path)
|
||||||
|
|
||||||
|
def test_external_pause_does_not_bleed_to_a_different_path(self):
|
||||||
|
first_path = "/srv/operator-playlists/first.m3u"
|
||||||
|
second_path = "/srv/operator-playlists/second.m3u"
|
||||||
|
self.reducer.handle_telemetry(
|
||||||
|
{
|
||||||
|
"state": "telemetry",
|
||||||
|
"path": first_path,
|
||||||
|
"paused": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIs(self.store.player_observation().paused, True)
|
||||||
|
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{"state": "playing", "path": second_path}
|
||||||
|
)
|
||||||
|
observation = self.store.player_observation()
|
||||||
|
self.assertEqual(observation.active_path, second_path)
|
||||||
|
self.assertIsNone(observation.paused)
|
||||||
|
self.assertEqual(observation.phase, "playing")
|
||||||
|
|
||||||
|
def test_stop_timeout_preserves_existing_operator_review_and_token(self):
|
||||||
|
path = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
|
||||||
|
self.reducer.handle_status(
|
||||||
|
{"state": "playing", "slot": 1, "path": path}
|
||||||
|
)
|
||||||
|
self.reducer.handle_status({"state": "offline"})
|
||||||
|
self.wall.value += 31
|
||||||
|
self.reducer.mark_due_reconciliation()
|
||||||
|
before = self.store.player_observation()
|
||||||
|
guard_before = self.store.list_live_guards()[0]
|
||||||
|
self.assertEqual(before.reconciliation_state, "operator_review")
|
||||||
|
|
||||||
|
self.reducer.begin_operation("stop", slot=1, expected_path=path)
|
||||||
|
self.monotonic.value += 6
|
||||||
|
effect = self.reducer.check_timeout()
|
||||||
|
self.assertEqual(effect.kind, "operation_timed_out")
|
||||||
|
after = self.store.player_observation()
|
||||||
|
guard_after = self.store.list_live_guards()[0]
|
||||||
|
self.assertEqual(after.reconciliation_state, "operator_review")
|
||||||
|
self.assertEqual(after.reconciliation_id, before.reconciliation_id)
|
||||||
|
self.assertEqual(guard_after.guard_id, guard_before.guard_id)
|
||||||
|
self.assertEqual(guard_after.state, "review_required")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from controller.config import ControllerConfig, RouterConfig, StorageConfig
|
||||||
|
from controller.mqtt_adapter import PublishReceipt
|
||||||
|
from controller.router import AudioRouterCoordinator
|
||||||
|
from controller.store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self, value=1000.0):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class ModePublisher:
|
||||||
|
def __init__(self):
|
||||||
|
self.modes = []
|
||||||
|
self.accepted = True
|
||||||
|
self.reason = None
|
||||||
|
|
||||||
|
def __call__(self, mode):
|
||||||
|
self.modes.append(mode)
|
||||||
|
return PublishReceipt(self.accepted, message_id=len(self.modes), reason=self.reason)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioRouterCoordinatorTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
base = Path(self.tempdir.name)
|
||||||
|
self.clock = Clock()
|
||||||
|
self.base_config = ControllerConfig(
|
||||||
|
storage=StorageConfig(
|
||||||
|
media_base=base / "media",
|
||||||
|
data_base=base / "data",
|
||||||
|
),
|
||||||
|
router=RouterConfig(
|
||||||
|
required_mode="video",
|
||||||
|
confirmation_timeout_seconds=3,
|
||||||
|
when_state_unknown="attempt",
|
||||||
|
continue_on_confirmation_failure=True,
|
||||||
|
fallback_restore_mode=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.store = ControllerStore(self.base_config.storage, now=self.clock).open()
|
||||||
|
self.publisher = ModePublisher()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
|
||||||
|
def coordinator(self, router_config=None):
|
||||||
|
config = self.base_config
|
||||||
|
if router_config is not None:
|
||||||
|
config = replace(config, router=router_config)
|
||||||
|
return AudioRouterCoordinator(
|
||||||
|
config,
|
||||||
|
self.store,
|
||||||
|
self.publisher,
|
||||||
|
clock=self.clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_known_mode_is_confirmed_before_play_and_restored_afterward(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-one")
|
||||||
|
self.assertEqual(decision.action, "waiting")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||||
|
lease = self.store.get_audio_lease(decision.lease_id)
|
||||||
|
self.assertEqual(lease.state, "held")
|
||||||
|
self.assertEqual(lease.previous_mode, "service")
|
||||||
|
restore = router.release(decision.lease_id)
|
||||||
|
self.assertEqual(restore.action, "restore_waiting")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||||
|
self.assertEqual(router.observe_mode("service").action, "restored")
|
||||||
|
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "released")
|
||||||
|
|
||||||
|
def test_default_unknown_state_timeout_continues_with_warning_not_hang(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
decision = router.acquire("play-timeout")
|
||||||
|
self.assertEqual(decision.action, "waiting")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
self.clock.value += 2.9
|
||||||
|
self.assertIsNone(router.check_deadline())
|
||||||
|
self.clock.value += 0.2
|
||||||
|
decision = router.check_deadline()
|
||||||
|
self.assertEqual(decision.action, "ready")
|
||||||
|
self.assertTrue(decision.continued)
|
||||||
|
self.assertIn("did not confirm", decision.warning)
|
||||||
|
lease = self.store.get_audio_lease(decision.lease_id)
|
||||||
|
self.assertEqual(lease.state, "unconfirmed")
|
||||||
|
self.assertEqual(router.release(lease.lease_id).action, "released")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
|
||||||
|
def test_timeout_can_reject_when_continue_policy_is_disabled(self):
|
||||||
|
router = self.coordinator(
|
||||||
|
replace(
|
||||||
|
self.base_config.router,
|
||||||
|
continue_on_confirmation_failure=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
decision = router.acquire("play-reject")
|
||||||
|
self.clock.value += 4
|
||||||
|
decision = router.check_deadline()
|
||||||
|
self.assertEqual(decision.action, "rejected")
|
||||||
|
self.assertFalse(decision.continued)
|
||||||
|
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "abandoned")
|
||||||
|
|
||||||
|
def test_unknown_state_continue_policy_does_not_wait_or_gain_late_ownership(self):
|
||||||
|
router = self.coordinator(
|
||||||
|
replace(self.base_config.router, when_state_unknown="continue")
|
||||||
|
)
|
||||||
|
decision = router.acquire("play-immediate")
|
||||||
|
self.assertEqual(decision.action, "ready")
|
||||||
|
self.assertTrue(decision.continued)
|
||||||
|
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "unconfirmed")
|
||||||
|
self.assertEqual(router.observe_mode("video").action, "observed")
|
||||||
|
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "unconfirmed")
|
||||||
|
|
||||||
|
def test_unknown_state_reject_policy_does_not_publish(self):
|
||||||
|
router = self.coordinator(
|
||||||
|
replace(self.base_config.router, when_state_unknown="reject")
|
||||||
|
)
|
||||||
|
decision = router.acquire("play-reject")
|
||||||
|
self.assertEqual(decision.action, "rejected")
|
||||||
|
self.assertEqual(self.publisher.modes, [])
|
||||||
|
|
||||||
|
def test_already_video_requires_no_change_and_no_restore(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("video")
|
||||||
|
decision = router.acquire("play-video")
|
||||||
|
self.assertEqual(decision.action, "ready")
|
||||||
|
self.assertEqual(self.publisher.modes, [])
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||||
|
self.assertEqual(self.publisher.modes, [])
|
||||||
|
|
||||||
|
def test_confirmed_unknown_acquisition_can_use_configured_fallback(self):
|
||||||
|
router = self.coordinator(
|
||||||
|
replace(self.base_config.router, fallback_restore_mode="service")
|
||||||
|
)
|
||||||
|
decision = router.acquire("play-fallback")
|
||||||
|
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||||
|
lease = self.store.get_audio_lease(decision.lease_id)
|
||||||
|
self.assertEqual(lease.restoration_source, "configured_fallback")
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||||
|
|
||||||
|
def test_operator_change_is_sticky_even_if_mode_later_returns_to_video(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-operator")
|
||||||
|
router.observe_mode("video")
|
||||||
|
lost = router.observe_mode("announcements")
|
||||||
|
self.assertEqual(lost.action, "ownership_lost")
|
||||||
|
router.observe_mode("video")
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(decision.lease_id).state,
|
||||||
|
"ownership_lost",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_connection_loss_relinquishes_confirmed_lease(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-disconnect")
|
||||||
|
router.observe_mode("video")
|
||||||
|
lost = router.connection_lost()
|
||||||
|
self.assertEqual(lost.action, "ownership_lost")
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
|
||||||
|
def test_publish_failure_uses_confirmation_failure_policy(self):
|
||||||
|
self.publisher.accepted = False
|
||||||
|
self.publisher.reason = "MQTT disconnected"
|
||||||
|
router = self.coordinator()
|
||||||
|
decision = router.acquire("play-publish-fail")
|
||||||
|
self.assertEqual(decision.action, "ready")
|
||||||
|
self.assertTrue(decision.continued)
|
||||||
|
self.assertIn("MQTT disconnected", decision.warning)
|
||||||
|
|
||||||
|
def test_restoration_timeout_is_bounded_and_not_retried(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-restore-timeout")
|
||||||
|
router.observe_mode("video")
|
||||||
|
router.release(decision.lease_id)
|
||||||
|
self.clock.value += 4
|
||||||
|
timeout = router.check_deadline()
|
||||||
|
self.assertEqual(timeout.action, "restore_failed")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||||
|
self.assertIsNone(router.check_deadline())
|
||||||
|
|
||||||
|
def test_repeated_previous_state_during_acquisition_waits_for_deadline(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
waiting = router.acquire("play-repeated-state")
|
||||||
|
self.assertEqual(router.observe_mode("service").action, "observed")
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(waiting.lease_id).state,
|
||||||
|
"acquiring",
|
||||||
|
)
|
||||||
|
self.clock.value += 4
|
||||||
|
decision = router.check_deadline()
|
||||||
|
self.assertEqual(decision.action, "ready")
|
||||||
|
self.assertTrue(decision.continued)
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(waiting.lease_id).state,
|
||||||
|
"unconfirmed",
|
||||||
|
)
|
||||||
|
router.release(waiting.lease_id)
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
|
||||||
|
def test_repeated_target_state_is_observation_not_operator_takeover(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-repeated-state")
|
||||||
|
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||||
|
self.assertEqual(router.observe_mode("video").action, "observed")
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(decision.lease_id).state,
|
||||||
|
"held",
|
||||||
|
)
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||||
|
|
||||||
|
def test_controller_mode_set_echo_does_not_relinquish_ownership(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-self-echo")
|
||||||
|
self.assertEqual(
|
||||||
|
router.observe_mode_command("video", controller_echo=True).action,
|
||||||
|
"self_echo",
|
||||||
|
)
|
||||||
|
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||||
|
self.assertEqual(
|
||||||
|
router.observe_mode_command("service", controller_echo=True).action,
|
||||||
|
"self_echo",
|
||||||
|
)
|
||||||
|
self.assertEqual(router.observe_mode("service").action, "restored")
|
||||||
|
|
||||||
|
def test_manual_mode_set_during_acquisition_uses_continue_policy(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
waiting = router.acquire("play-manual-acquire")
|
||||||
|
decision = router.observe_mode_command("announcements")
|
||||||
|
self.assertEqual(decision.action, "ready")
|
||||||
|
self.assertTrue(decision.continued)
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(waiting.lease_id).state,
|
||||||
|
"unconfirmed",
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.store.get_audio_lease(waiting.lease_id).confirmation_deadline)
|
||||||
|
|
||||||
|
def test_manual_mode_set_during_acquisition_can_reject(self):
|
||||||
|
router = self.coordinator(
|
||||||
|
replace(
|
||||||
|
self.base_config.router,
|
||||||
|
continue_on_confirmation_failure=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
router.observe_mode("service")
|
||||||
|
waiting = router.acquire("play-manual-reject")
|
||||||
|
decision = router.observe_mode_command("announcements")
|
||||||
|
self.assertEqual(decision.action, "rejected")
|
||||||
|
self.assertFalse(decision.continued)
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(waiting.lease_id).state,
|
||||||
|
"abandoned",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manual_mode_set_while_held_relinquishes_restoration(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-manual-held")
|
||||||
|
router.observe_mode("video")
|
||||||
|
lost = router.observe_mode_command("video")
|
||||||
|
self.assertEqual(lost.action, "ownership_lost")
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||||
|
self.assertEqual(self.publisher.modes, ["video"])
|
||||||
|
|
||||||
|
def test_manual_mode_set_while_restoring_relinquishes_restoration(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-manual-restore")
|
||||||
|
router.observe_mode("video")
|
||||||
|
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||||
|
lost = router.observe_mode_command("announcements")
|
||||||
|
self.assertEqual(lost.action, "ownership_lost")
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(decision.lease_id).state,
|
||||||
|
"ownership_lost",
|
||||||
|
)
|
||||||
|
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||||
|
|
||||||
|
def test_retained_mode_set_does_not_claim_live_operator_intent(self):
|
||||||
|
router = self.coordinator()
|
||||||
|
router.observe_mode("service")
|
||||||
|
decision = router.acquire("play-retained-set")
|
||||||
|
router.observe_mode("video")
|
||||||
|
ignored = router.observe_mode_command("announcements", retained=True)
|
||||||
|
self.assertEqual(ignored.action, "retained_ignored")
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.get_audio_lease(decision.lease_id).state,
|
||||||
|
"held",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Real-broker smoke test for Audio Router self-echo correlation.
|
||||||
|
|
||||||
|
The fake MQTT client used by ``tests/test_mqtt_adapter.py`` delivers a
|
||||||
|
publish's own echo synchronously, inside the ``publish()`` call itself, so it
|
||||||
|
cannot exercise the actual ordering risk: a real broker may legally deliver a
|
||||||
|
Controller-originated Router mode-set message back through the Controller's
|
||||||
|
own subscription either before or after the PUBACK that already acknowledged
|
||||||
|
the same publish. This test runs the real ``MqttAdapter`` against a real
|
||||||
|
Mosquitto broker in Docker to prove that ordering no longer matters.
|
||||||
|
|
||||||
|
Skipped automatically when Docker (or a Mosquitto broker container) is not
|
||||||
|
available, so it never blocks the primary regression suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from controller.config import ControllerConfig, MqttConfig
|
||||||
|
from controller.mqtt_adapter import MqttAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_available() -> bool:
|
||||||
|
return shutil.which("docker") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return sock.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
_docker_available(), "docker is not available for a real Mosquitto broker"
|
||||||
|
)
|
||||||
|
class RouterSelfEchoMosquittoIntegrationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.port = _free_port()
|
||||||
|
cls.container_name = f"controller-echo-test-{uuid.uuid4().hex[:10]}"
|
||||||
|
cls.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
conf_path = Path(cls.tempdir.name) / "mosquitto.conf"
|
||||||
|
conf_path.write_text("listener 1883\nallow_anonymous true\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--rm",
|
||||||
|
"--name",
|
||||||
|
cls.container_name,
|
||||||
|
"-p",
|
||||||
|
f"127.0.0.1:{cls.port}:1883",
|
||||||
|
"-v",
|
||||||
|
f"{conf_path}:/mosquitto/config/mosquitto.conf:ro",
|
||||||
|
"eclipse-mosquitto",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||||
|
cls.tempdir.cleanup()
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
f"could not start a real Mosquitto broker container: {exc}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cls._wait_for_broker(cls.port)
|
||||||
|
except AssertionError as exc:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", cls.container_name],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
cls.tempdir.cleanup()
|
||||||
|
raise unittest.SkipTest(str(exc))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", cls.container_name],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
cls.tempdir.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wait_for_broker(port: int, timeout: float = 15.0) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last_exc: OSError | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise AssertionError(f"Mosquitto broker never became reachable: {last_exc}")
|
||||||
|
|
||||||
|
def _config(self) -> ControllerConfig:
|
||||||
|
return ControllerConfig(
|
||||||
|
mqtt=MqttConfig(
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=self.port,
|
||||||
|
client_id=f"controller-echo-{uuid.uuid4().hex[:10]}",
|
||||||
|
reconnect_min_seconds=1,
|
||||||
|
reconnect_max_seconds=1,
|
||||||
|
command_queue_size=32,
|
||||||
|
publish_ack_timeout_seconds=5.0,
|
||||||
|
router_echo_grace_seconds=2.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_controller_recognizes_its_own_router_echo_over_a_real_broker(self) -> None:
|
||||||
|
config = self._config()
|
||||||
|
adapter = MqttAdapter(config)
|
||||||
|
adapter.start()
|
||||||
|
try:
|
||||||
|
self._wait_until(
|
||||||
|
lambda: adapter.connected,
|
||||||
|
"Controller MQTT adapter never connected to the real broker",
|
||||||
|
)
|
||||||
|
receipt = adapter.publish_router_mode("video")
|
||||||
|
self.assertTrue(receipt.accepted, receipt.reason)
|
||||||
|
|
||||||
|
event = self._next_message_event(adapter, timeout=5.0)
|
||||||
|
self.assertEqual(event.topic, config.topics.router_mode_set)
|
||||||
|
self.assertEqual(event.payload, b"video")
|
||||||
|
self.assertTrue(
|
||||||
|
event.controller_echo,
|
||||||
|
"Controller did not recognize its own Router mode-set echo "
|
||||||
|
"delivered by a real broker; the PUBACK-vs-subscription-echo "
|
||||||
|
"race was not closed",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
adapter.stop()
|
||||||
|
|
||||||
|
def test_genuine_external_router_command_is_not_treated_as_self_echo(self) -> None:
|
||||||
|
config = self._config()
|
||||||
|
adapter = MqttAdapter(config)
|
||||||
|
adapter.start()
|
||||||
|
operator = mqtt.Client(
|
||||||
|
mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=f"operator-{uuid.uuid4().hex[:10]}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._wait_until(
|
||||||
|
lambda: adapter.connected,
|
||||||
|
"Controller MQTT adapter never connected to the real broker",
|
||||||
|
)
|
||||||
|
operator.connect(config.mqtt.host, config.mqtt.port, keepalive=30)
|
||||||
|
operator.loop_start()
|
||||||
|
info = operator.publish(
|
||||||
|
config.topics.router_mode_set, "service", qos=1, retain=False
|
||||||
|
)
|
||||||
|
info.wait_for_publish(timeout=5.0)
|
||||||
|
|
||||||
|
event = self._next_message_event(adapter, timeout=5.0)
|
||||||
|
self.assertEqual(event.payload, b"service")
|
||||||
|
self.assertFalse(
|
||||||
|
event.controller_echo,
|
||||||
|
"A genuine external Router command was mistaken for a "
|
||||||
|
"Controller echo; automatic restoration ownership would be "
|
||||||
|
"incorrectly preserved instead of relinquished",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
operator.loop_stop()
|
||||||
|
operator.disconnect()
|
||||||
|
adapter.stop()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wait_until(predicate, message: str, timeout: float = 10.0) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise AssertionError(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _next_message_event(adapter: MqttAdapter, *, timeout: float):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
event = adapter.events.get(timeout=0.2)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
if event.kind == "message":
|
||||||
|
return event
|
||||||
|
raise AssertionError("no MQTT message event was observed within the timeout")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import ast
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name: str) -> str:
|
||||||
|
return (ROOT / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_logical_lines(text: str) -> list[str]:
|
||||||
|
logical: list[str] = []
|
||||||
|
pending = ""
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if not stripped or (not pending and stripped.startswith("#")):
|
||||||
|
continue
|
||||||
|
pending = f"{pending} {stripped}".strip()
|
||||||
|
if pending.endswith("\\"):
|
||||||
|
pending = pending[:-1].rstrip()
|
||||||
|
continue
|
||||||
|
logical.append(pending)
|
||||||
|
pending = ""
|
||||||
|
if pending:
|
||||||
|
logical.append(pending)
|
||||||
|
return logical
|
||||||
|
|
||||||
|
|
||||||
|
def _environment_map(value: Any) -> dict[str, str | None]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): None if item is None else str(item) for key, item in value.items()}
|
||||||
|
result: dict[str, str | None] = {}
|
||||||
|
for entry in value or []:
|
||||||
|
key, separator, item = str(entry).partition("=")
|
||||||
|
result[key] = item if separator else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _volume_mounts(value: Any) -> list[tuple[str | None, str, bool]]:
|
||||||
|
mounts: list[tuple[str | None, str, bool]] = []
|
||||||
|
for entry in value or []:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
target = str(entry.get("target") or "")
|
||||||
|
if target:
|
||||||
|
mounts.append(
|
||||||
|
(
|
||||||
|
None if entry.get("source") is None else str(entry["source"]),
|
||||||
|
target,
|
||||||
|
bool(entry.get("read_only", False)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# Greedy source matching tolerates Compose substitutions containing
|
||||||
|
# `:-` or `:?` while keeping the absolute container target distinct.
|
||||||
|
match = re.match(r"^(.*):(/[^:]+)(?::([^:]+))?$", str(entry))
|
||||||
|
if match:
|
||||||
|
mode = match.group(3) or ""
|
||||||
|
mounts.append((match.group(1), match.group(2), "ro" in mode.split(",")))
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_targets(value: Any) -> set[str]:
|
||||||
|
targets: set[str] = set()
|
||||||
|
for entry in value or []:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
source = str(entry.get("source") or "")
|
||||||
|
target = str(entry.get("target") or source)
|
||||||
|
else:
|
||||||
|
source = str(entry)
|
||||||
|
target = source
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
targets.add(target if target.startswith("/") else f"/run/secrets/{target}")
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementPinTests(unittest.TestCase):
|
||||||
|
def test_runtime_requirements_are_exactly_pinned(self):
|
||||||
|
path = ROOT / "requirements.txt"
|
||||||
|
self.assertTrue(path.is_file(), "requirements.txt is missing")
|
||||||
|
requirements: dict[str, str] = {}
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,.-]+\])?==([^;\s]+)(?:\s*;.*)?$"
|
||||||
|
)
|
||||||
|
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
line = raw_line.split("#", 1)[0].strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
match = pattern.fullmatch(line)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
match,
|
||||||
|
f"requirements.txt:{line_number} is not an exact == pin: {line!r}",
|
||||||
|
)
|
||||||
|
assert match is not None
|
||||||
|
normalized = re.sub(r"[-_.]+", "-", match.group(1)).lower()
|
||||||
|
requirements[normalized] = match.group(2)
|
||||||
|
|
||||||
|
for project in ("flask", "gunicorn", "paho-mqtt", "pyyaml", "yt-dlp"):
|
||||||
|
self.assertIn(project, requirements, f"missing runtime dependency {project}")
|
||||||
|
self.assertNotIn("*", requirements[project])
|
||||||
|
|
||||||
|
|
||||||
|
class DockerfileTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.path = ROOT / "Dockerfile"
|
||||||
|
self.assertTrue(self.path.is_file(), "Dockerfile is missing")
|
||||||
|
self.text = self.path.read_text(encoding="utf-8")
|
||||||
|
self.lines = _docker_logical_lines(self.text)
|
||||||
|
|
||||||
|
def test_image_copies_the_v2_runtime(self):
|
||||||
|
copies = "\n".join(
|
||||||
|
line for line in self.lines if line.upper().startswith("COPY ")
|
||||||
|
)
|
||||||
|
for required in (
|
||||||
|
"controller",
|
||||||
|
"config.yaml",
|
||||||
|
"app.py",
|
||||||
|
"wsgi.py",
|
||||||
|
"gunicorn.conf.py",
|
||||||
|
):
|
||||||
|
self.assertIn(required, copies, f"Dockerfile does not copy {required}")
|
||||||
|
|
||||||
|
def test_image_runs_non_root_on_the_controller_port(self):
|
||||||
|
users = [line.split(None, 1)[1] for line in self.lines if line.upper().startswith("USER ")]
|
||||||
|
self.assertTrue(users, "Dockerfile must set a non-root USER")
|
||||||
|
self.assertNotIn(users[-1].strip().lower(), {"root", "0", "0:0"})
|
||||||
|
self.assertRegex(self.text, r"(?im)^\s*EXPOSE\s+7531(?:/tcp)?\s*$")
|
||||||
|
|
||||||
|
def test_image_has_an_http_healthcheck_and_runs_gunicorn(self):
|
||||||
|
self.assertRegex(self.text, r"(?im)^\s*HEALTHCHECK\b")
|
||||||
|
self.assertIn("/healthz", self.text)
|
||||||
|
launch = "\n".join(
|
||||||
|
line
|
||||||
|
for line in self.lines
|
||||||
|
if line.upper().startswith(("CMD ", "ENTRYPOINT "))
|
||||||
|
).lower()
|
||||||
|
self.assertIn("gunicorn", launch)
|
||||||
|
|
||||||
|
def test_image_has_no_unbounded_yt_dlp_upgrade_or_hidden_global_config(self):
|
||||||
|
self.assertNotRegex(
|
||||||
|
self.text,
|
||||||
|
r"(?i)(?:--upgrade|-U)\s+[\"']?(?:yt-dlp|yt_dlp)",
|
||||||
|
)
|
||||||
|
self.assertNotIn("/etc/yt-dlp.conf", self.text)
|
||||||
|
|
||||||
|
def test_image_verifies_a_supported_node_runtime_for_yt_dlp(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.text,
|
||||||
|
r"(?im)^FROM\s+node:22\.\d+\.\d+-bookworm-slim\s+AS\s+",
|
||||||
|
)
|
||||||
|
self.assertIn("NodeJsRuntime", self.text)
|
||||||
|
self.assertIn("info.supported", self.text)
|
||||||
|
|
||||||
|
|
||||||
|
class GunicornConfigTests(unittest.TestCase):
|
||||||
|
def test_gunicorn_uses_exactly_one_worker_without_preload(self):
|
||||||
|
path = ROOT / "gunicorn.conf.py"
|
||||||
|
self.assertTrue(path.is_file(), "gunicorn.conf.py is missing")
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
assignments: dict[str, Any] = {}
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
continue
|
||||||
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||||
|
try:
|
||||||
|
value = ast.literal_eval(node.value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
for target in targets:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
assignments[target.id] = value
|
||||||
|
|
||||||
|
self.assertEqual(assignments.get("workers"), 1)
|
||||||
|
self.assertIs(assignments.get("preload_app"), False)
|
||||||
|
self.assertIs(assignments.get("control_socket_disable"), True)
|
||||||
|
self.assertEqual(assignments.get("worker_tmp_dir"), "/tmp")
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeRuntimeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.path = ROOT / "docker-compose.yml"
|
||||||
|
self.assertTrue(self.path.is_file(), "docker-compose.yml is missing")
|
||||||
|
self.text = self.path.read_text(encoding="utf-8")
|
||||||
|
document = yaml.safe_load(self.text)
|
||||||
|
self.assertIsInstance(document, dict)
|
||||||
|
self.document = document
|
||||||
|
self.service = document.get("services", {}).get("controller")
|
||||||
|
self.assertIsInstance(self.service, dict, "Compose controller service is missing")
|
||||||
|
|
||||||
|
def test_compose_requires_a_real_flask_secret_and_publishes_port_7531(self):
|
||||||
|
environment = _environment_map(self.service.get("environment"))
|
||||||
|
secret = environment.get("FLASK_SECRET_KEY")
|
||||||
|
self.assertIsNotNone(secret, "FLASK_SECRET_KEY environment setting is missing")
|
||||||
|
assert secret is not None
|
||||||
|
self.assertTrue(secret.startswith("${FLASK_SECRET_KEY"))
|
||||||
|
self.assertIn("?", secret, "FLASK_SECRET_KEY must use required Compose substitution")
|
||||||
|
self.assertNotIn("change-me", secret.lower())
|
||||||
|
self.assertNotIn("dev-secret", secret.lower())
|
||||||
|
|
||||||
|
published_targets: set[str] = set()
|
||||||
|
for entry in self.service.get("ports") or []:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
published_targets.add(str(entry.get("target")))
|
||||||
|
else:
|
||||||
|
match = re.search(r":(\d+)(?:/(?:tcp|udp))?$", str(entry))
|
||||||
|
if match:
|
||||||
|
published_targets.add(match.group(1))
|
||||||
|
self.assertIn("7531", published_targets)
|
||||||
|
|
||||||
|
def test_compose_passes_all_supported_mqtt_environment_overrides(self):
|
||||||
|
environment = _environment_map(self.service.get("environment"))
|
||||||
|
for name in (
|
||||||
|
"MQTT_HOST",
|
||||||
|
"MQTT_PORT",
|
||||||
|
"MQTT_USERNAME",
|
||||||
|
"MQTT_PASSWORD",
|
||||||
|
"MQTT_CLIENT_ID",
|
||||||
|
):
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertIn(name, environment)
|
||||||
|
|
||||||
|
def test_compose_uses_dedicated_data_media_and_cookie_mounts(self):
|
||||||
|
mounts = _volume_mounts(self.service.get("volumes"))
|
||||||
|
by_target = {target: (source, read_only) for source, target, read_only in mounts}
|
||||||
|
self.assertIn("/controller-data", by_target)
|
||||||
|
self.assertIn("/mnt/Media/YTPlayer", by_target)
|
||||||
|
self.assertNotEqual(by_target["/controller-data"][0], "/home/media/Scripts/Video-Player")
|
||||||
|
|
||||||
|
cookie_path = "/run/secrets/yt-dlp-cookies.txt"
|
||||||
|
mounted_cookie = cookie_path in by_target and by_target[cookie_path][1]
|
||||||
|
mounted_secret_directory = (
|
||||||
|
"/run/secrets" in by_target and by_target["/run/secrets"][1]
|
||||||
|
)
|
||||||
|
secret_cookie = cookie_path in _secret_targets(self.service.get("secrets"))
|
||||||
|
self.assertTrue(
|
||||||
|
mounted_cookie or mounted_secret_directory or secret_cookie,
|
||||||
|
f"cookies must be read-only at exactly {cookie_path}",
|
||||||
|
)
|
||||||
|
self.assertNotIn("/cookies_ro.txt", self.text)
|
||||||
|
self.assertNotRegex(
|
||||||
|
self.text,
|
||||||
|
r"/home/media/Scripts/Video-Player(?=[:\s\"'])",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compose_enables_container_hardening_and_bounded_operations(self):
|
||||||
|
self.assertIs(self.service.get("read_only"), True)
|
||||||
|
self.assertIs(self.service.get("init"), True)
|
||||||
|
|
||||||
|
cap_drop = self.service.get("cap_drop") or []
|
||||||
|
if isinstance(cap_drop, str):
|
||||||
|
cap_drop = [cap_drop]
|
||||||
|
self.assertIn("ALL", {str(value).upper() for value in cap_drop})
|
||||||
|
|
||||||
|
security_options = self.service.get("security_opt") or []
|
||||||
|
if isinstance(security_options, str):
|
||||||
|
security_options = [security_options]
|
||||||
|
self.assertTrue(
|
||||||
|
any("no-new-privileges" in str(value).lower() for value in security_options)
|
||||||
|
)
|
||||||
|
|
||||||
|
tmpfs = self.service.get("tmpfs") or []
|
||||||
|
if isinstance(tmpfs, str):
|
||||||
|
tmpfs = [tmpfs]
|
||||||
|
self.assertTrue(any(str(value).split(":", 1)[0] == "/tmp" for value in tmpfs))
|
||||||
|
self.assertTrue(any("noexec" in str(value) for value in tmpfs))
|
||||||
|
|
||||||
|
healthcheck = self.service.get("healthcheck")
|
||||||
|
self.assertIsInstance(healthcheck, dict)
|
||||||
|
self.assertIn("/healthz", str(healthcheck.get("test")))
|
||||||
|
|
||||||
|
logging_config = self.service.get("logging")
|
||||||
|
self.assertIsInstance(logging_config, dict)
|
||||||
|
options = logging_config.get("options") or {}
|
||||||
|
self.assertTrue(options.get("max-size"), "logging max-size is missing")
|
||||||
|
self.assertTrue(options.get("max-file"), "logging max-file is missing")
|
||||||
|
grace = str(self.service.get("stop_grace_period") or "").rstrip("s")
|
||||||
|
self.assertGreaterEqual(float(grace), 30.0)
|
||||||
|
|
||||||
|
def test_compose_preserves_the_external_tabernet_network(self):
|
||||||
|
service_networks = self.service.get("networks") or []
|
||||||
|
if isinstance(service_networks, dict):
|
||||||
|
service_networks = service_networks.keys()
|
||||||
|
self.assertIn("tabernet", service_networks)
|
||||||
|
tabernet = self.document.get("networks", {}).get("tabernet")
|
||||||
|
self.assertIsInstance(tabernet, dict)
|
||||||
|
self.assertIs(tabernet.get("external"), True)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerIgnoreTests(unittest.TestCase):
|
||||||
|
def test_dockerignore_excludes_secrets_and_python_caches(self):
|
||||||
|
path = ROOT / ".dockerignore"
|
||||||
|
self.assertTrue(path.is_file(), ".dockerignore is missing")
|
||||||
|
patterns = {
|
||||||
|
line.strip().lower()
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
}
|
||||||
|
self.assertTrue(any("__pycache__" in pattern for pattern in patterns))
|
||||||
|
self.assertTrue(
|
||||||
|
any("pyc" in pattern or "py[cod]" in pattern for pattern in patterns)
|
||||||
|
)
|
||||||
|
self.assertTrue(any(pattern in {".env", ".env*", "*.env"} for pattern in patterns))
|
||||||
|
self.assertTrue(any("secret" in pattern for pattern in patterns))
|
||||||
|
self.assertTrue(any("cookie" in pattern for pattern in patterns))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,570 @@
|
|||||||
|
import concurrent.futures
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from controller.config import StorageConfig
|
||||||
|
from controller.models import MigrationError, SlotProtectedError
|
||||||
|
from controller.store import ControllerStore, LEGACY_MIGRATION_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class MutableClock:
|
||||||
|
def __init__(self, value=1_700_000_000.0):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
self.base = Path(self.tempdir.name)
|
||||||
|
self.media = self.base / "media"
|
||||||
|
self.data = self.base / "data"
|
||||||
|
self.clock = MutableClock()
|
||||||
|
self.config = StorageConfig(
|
||||||
|
media_base=self.media,
|
||||||
|
data_base=self.data,
|
||||||
|
retention_seconds=100,
|
||||||
|
uncertain_review_after_seconds=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
|
||||||
|
def open_store(self):
|
||||||
|
return ControllerStore(self.config, now=self.clock).open()
|
||||||
|
|
||||||
|
|
||||||
|
class FreshStoreTests(StoreTestCase):
|
||||||
|
def test_fresh_database_seeds_exact_stable_slots(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
slots = store.list_slots()
|
||||||
|
self.assertEqual([slot.slot for slot in slots], list(range(1, 11)))
|
||||||
|
self.assertTrue(all(slot.media_state == "empty" for slot in slots))
|
||||||
|
self.assertEqual(store.player_observation().phase, "unknown")
|
||||||
|
|
||||||
|
def test_schema_one_database_upgrades_external_reconciliation_atomically(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
database_path = store.path
|
||||||
|
connection = sqlite3.connect(database_path)
|
||||||
|
try:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE player_observation DROP COLUMN reconciliation_id"
|
||||||
|
)
|
||||||
|
connection.execute("DELETE FROM schema_migrations WHERE version = 2")
|
||||||
|
connection.execute("PRAGMA user_version = 1")
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with self.open_store() as upgraded:
|
||||||
|
self.assertEqual(
|
||||||
|
upgraded.connection.execute("PRAGMA user_version").fetchone()[0],
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in upgraded.connection.execute(
|
||||||
|
"PRAGMA table_info(player_observation)"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
self.assertIn("reconciliation_id", columns)
|
||||||
|
migration = upgraded.connection.execute(
|
||||||
|
"SELECT name FROM schema_migrations WHERE version = 2"
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(migration["name"], "external_playback_reconciliation")
|
||||||
|
|
||||||
|
def test_slot_reservation_is_committed_before_worker_transition(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
reservation = store.reserve_download(
|
||||||
|
"https://www.youtube.com/playlist?list=one",
|
||||||
|
"Alice",
|
||||||
|
"Service",
|
||||||
|
job_id="job-one",
|
||||||
|
)
|
||||||
|
self.assertEqual(reservation.status, "reserved")
|
||||||
|
self.assertEqual(reservation.slot, 1)
|
||||||
|
self.assertEqual(store.get_slot(1).media_state, "queued")
|
||||||
|
job = store.get_job("job-one")
|
||||||
|
self.assertIsNotNone(job)
|
||||||
|
self.assertEqual(job.status, "reserved")
|
||||||
|
self.assertTrue(store.mark_download_running("job-one", 1, 1))
|
||||||
|
self.assertEqual(store.get_slot(1).media_state, "downloading")
|
||||||
|
|
||||||
|
def test_find_occupied_slot_for_url_only_matches_non_empty_slots(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
url = "https://www.youtube.com/playlist?list=one"
|
||||||
|
self.assertIsNone(store.find_occupied_slot_for_url(url))
|
||||||
|
self.assertIsNone(store.find_occupied_slot_for_url(" "))
|
||||||
|
self.assertIsNone(store.find_occupied_slot_for_url(None))
|
||||||
|
|
||||||
|
store.reserve_download(url, None, None, job_id="job-one")
|
||||||
|
self.assertEqual(store.find_occupied_slot_for_url(url), 1)
|
||||||
|
self.assertEqual(store.find_occupied_slot_for_url(f" {url} "), 1)
|
||||||
|
self.assertIsNone(
|
||||||
|
store.find_occupied_slot_for_url("https://example.test/other")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_concurrent_requests_get_only_one_remaining_slot(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
for index in range(1, 10):
|
||||||
|
result = store.reserve_download(
|
||||||
|
f"https://example.test/{index}",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id=f"seed-{index}",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.slot, index)
|
||||||
|
|
||||||
|
def reserve(index):
|
||||||
|
return store.reserve_download(
|
||||||
|
f"https://example.test/concurrent-{index}",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id=f"concurrent-{index}",
|
||||||
|
)
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
results = list(pool.map(reserve, range(8)))
|
||||||
|
winners = [result for result in results if result.status == "reserved"]
|
||||||
|
self.assertEqual(len(winners), 1)
|
||||||
|
self.assertEqual(winners[0].slot, 10)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(result.status == "queue_full" for result in results),
|
||||||
|
7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_downloads_receive_expiry_and_cannot_exhaust_slots_forever(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
for slot in range(1, 11):
|
||||||
|
reservation = store.reserve_download(
|
||||||
|
f"https://example.test/failure-{slot}",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
job_id=f"failure-{slot}",
|
||||||
|
)
|
||||||
|
self.assertEqual(reservation.slot, slot)
|
||||||
|
self.assertTrue(
|
||||||
|
store.fail_download(
|
||||||
|
reservation.job_id,
|
||||||
|
slot,
|
||||||
|
reservation.generation,
|
||||||
|
"download failed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
store.get_slot(slot).expires_at,
|
||||||
|
self.clock.value + self.config.retention_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
store.reserve_download(
|
||||||
|
"https://example.test/temporarily-full",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).status,
|
||||||
|
"queue_full",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.clock.value += self.config.retention_seconds + 1
|
||||||
|
tokens = store.begin_expired_deletions()
|
||||||
|
self.assertEqual([token.slot for token in tokens], list(range(1, 11)))
|
||||||
|
for token in tokens:
|
||||||
|
self.assertTrue(store.finish_delete(token))
|
||||||
|
|
||||||
|
replacement = store.reserve_download(
|
||||||
|
"https://example.test/recovered-capacity",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
self.assertEqual(replacement.slot, 1)
|
||||||
|
|
||||||
|
def test_stale_worker_cannot_overwrite_reused_slot(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
first = store.reserve_download("https://example.test/one", None, None, job_id="old")
|
||||||
|
store.mark_download_running("old", 1, first.generation)
|
||||||
|
store.fail_download("old", 1, first.generation, "failed")
|
||||||
|
deletion = store.begin_delete(1, reason="test")
|
||||||
|
self.assertTrue(store.finish_delete(deletion))
|
||||||
|
second = store.reserve_download("https://example.test/two", None, None, job_id="new")
|
||||||
|
self.assertEqual(second.slot, 1)
|
||||||
|
self.assertFalse(
|
||||||
|
store.complete_download(
|
||||||
|
"old",
|
||||||
|
1,
|
||||||
|
first.generation,
|
||||||
|
title="stale",
|
||||||
|
items=(("Stale", "stale.mp4"),),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(store.get_slot(1).url, "https://example.test/two")
|
||||||
|
|
||||||
|
def test_deleting_slot_does_not_renumber_other_slots(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
first = store.reserve_download("https://example.test/one", None, None, job_id="one")
|
||||||
|
second = store.reserve_download("https://example.test/two", None, None, job_id="two")
|
||||||
|
store.fail_download("one", 1, first.generation, "failed")
|
||||||
|
store.fail_download("two", 2, second.generation, "failed")
|
||||||
|
token = store.begin_delete(1, reason="manual")
|
||||||
|
store.finish_delete(token)
|
||||||
|
self.assertEqual(store.get_slot(1).media_state, "empty")
|
||||||
|
self.assertEqual(store.get_slot(2).slot, 2)
|
||||||
|
self.assertEqual(store.get_slot(2).url, "https://example.test/two")
|
||||||
|
|
||||||
|
def test_concurrent_first_open_installs_only_one_valid_database(self):
|
||||||
|
stores = [ControllerStore(self.config, now=self.clock) for _ in range(2)]
|
||||||
|
|
||||||
|
def open_store(store):
|
||||||
|
store.open()
|
||||||
|
return [slot.slot for slot in store.list_slots()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
results = list(pool.map(open_store, stores))
|
||||||
|
self.assertEqual(results, [list(range(1, 11)), list(range(1, 11))])
|
||||||
|
markers = stores[0].connection.execute(
|
||||||
|
"SELECT name FROM data_migrations ORDER BY name"
|
||||||
|
).fetchall()
|
||||||
|
self.assertEqual([row["name"] for row in markers], ["fresh_database_v1"])
|
||||||
|
finally:
|
||||||
|
for store in stores:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class GuardAndExpiryTests(StoreTestCase):
|
||||||
|
def _ready_slot(self, store, *, slot_expected=1):
|
||||||
|
reservation = store.reserve_download("https://example.test/ready", None, None, job_id="ready")
|
||||||
|
self.assertEqual(reservation.slot, slot_expected)
|
||||||
|
store.mark_download_running("ready", reservation.slot, reservation.generation)
|
||||||
|
store.complete_download(
|
||||||
|
"ready",
|
||||||
|
reservation.slot,
|
||||||
|
reservation.generation,
|
||||||
|
title="Ready",
|
||||||
|
items=(("One", "one.mp4"),),
|
||||||
|
)
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
def test_active_and_review_guards_block_delete_and_expiry(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
reservation = self._ready_slot(store)
|
||||||
|
guard = store.protect_slot(
|
||||||
|
1,
|
||||||
|
"restart_uncertain",
|
||||||
|
guard_id="review-me",
|
||||||
|
review_after=self.clock.value + 10,
|
||||||
|
)
|
||||||
|
self.clock.value += 200
|
||||||
|
due = store.mark_due_guards_for_review()
|
||||||
|
self.assertEqual(due[0].state, "review_required")
|
||||||
|
with self.assertRaises(SlotProtectedError):
|
||||||
|
store.begin_delete(1, reason="manual")
|
||||||
|
self.assertEqual(store.begin_expired_deletions(), [])
|
||||||
|
self.assertTrue(
|
||||||
|
store.clear_guard(
|
||||||
|
1,
|
||||||
|
guard.guard_id,
|
||||||
|
actor="operator",
|
||||||
|
reason="Player host replaced",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(store.player_observation().phase, "unknown")
|
||||||
|
expired = store.begin_expired_deletions()
|
||||||
|
self.assertEqual([token.slot for token in expired], [reservation.slot])
|
||||||
|
|
||||||
|
def test_admin_clear_requires_current_token_actor_and_reason(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
self._ready_slot(store)
|
||||||
|
guard = store.protect_slot(1, "player_active", guard_id="current")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
store.clear_guard(1, guard.guard_id, actor="", reason="because")
|
||||||
|
self.assertFalse(
|
||||||
|
store.clear_guard(
|
||||||
|
1,
|
||||||
|
"stale-token",
|
||||||
|
actor="operator",
|
||||||
|
reason="confirmed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(store.list_live_guards()[0].guard_id, "current")
|
||||||
|
|
||||||
|
def test_restart_marks_last_active_slot_uncertain_without_claiming_idle(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
self._ready_slot(store)
|
||||||
|
store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="playing",
|
||||||
|
active_slot=1,
|
||||||
|
active_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
paused=False,
|
||||||
|
reconciliation_state="confirmed",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
)
|
||||||
|
guard = store.mark_restart_uncertain()
|
||||||
|
self.assertEqual(guard.slot, 1)
|
||||||
|
observation = store.player_observation()
|
||||||
|
self.assertEqual(observation.phase, "unknown")
|
||||||
|
self.assertEqual(observation.reconciliation_state, "awaiting_evidence")
|
||||||
|
self.assertEqual(observation.active_slot, 1)
|
||||||
|
|
||||||
|
def test_external_playback_restart_gets_stable_review_token_and_exact_clear(self):
|
||||||
|
external_path = "/srv/operator-playlists/live.m3u"
|
||||||
|
with self.open_store() as store:
|
||||||
|
store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="playing",
|
||||||
|
active_slot=None,
|
||||||
|
active_path=external_path,
|
||||||
|
paused=False,
|
||||||
|
reconciliation_state="confirmed",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
)
|
||||||
|
self.assertIsNone(store.mark_restart_uncertain())
|
||||||
|
first = store.player_observation()
|
||||||
|
self.assertEqual(first.reconciliation_state, "awaiting_evidence")
|
||||||
|
self.assertIsNotNone(first.reconciliation_id)
|
||||||
|
self.assertEqual(first.reconciliation_due_at, self.clock.value + 30)
|
||||||
|
|
||||||
|
self.clock.value += 10
|
||||||
|
self.assertIsNone(store.mark_restart_uncertain())
|
||||||
|
repeated = store.player_observation()
|
||||||
|
self.assertEqual(repeated.reconciliation_id, first.reconciliation_id)
|
||||||
|
self.assertEqual(repeated.reconciliation_due_at, first.reconciliation_due_at)
|
||||||
|
|
||||||
|
self.clock.value = first.reconciliation_due_at + 1
|
||||||
|
due = store.mark_external_reconciliation_due()
|
||||||
|
self.assertEqual(due, (first.reconciliation_id, external_path))
|
||||||
|
self.assertIsNone(store.mark_external_reconciliation_due())
|
||||||
|
review = store.player_observation()
|
||||||
|
self.assertEqual(review.reconciliation_state, "operator_review")
|
||||||
|
self.assertEqual(review.active_path, external_path)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
store.clear_external_reconciliation(
|
||||||
|
"stale-review",
|
||||||
|
actor="operator",
|
||||||
|
reason="Player was replaced",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
store.clear_external_reconciliation(
|
||||||
|
first.reconciliation_id,
|
||||||
|
actor="operator",
|
||||||
|
reason="Player was replaced",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cleared = store.player_observation()
|
||||||
|
self.assertEqual(cleared.reconciliation_state, "admin_cleared")
|
||||||
|
self.assertIsNone(cleared.active_path)
|
||||||
|
self.assertIsNone(cleared.reconciliation_id)
|
||||||
|
|
||||||
|
def test_restart_converts_existing_active_guard_and_preserves_deadline(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
self._ready_slot(store)
|
||||||
|
store.protect_slot(1, "player_active", guard_id="active-before-restart")
|
||||||
|
store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="playing",
|
||||||
|
active_slot=1,
|
||||||
|
active_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||||
|
paused=False,
|
||||||
|
reconciliation_state="confirmed",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
)
|
||||||
|
guard = store.mark_restart_uncertain()
|
||||||
|
self.assertEqual(guard.guard_id, "active-before-restart")
|
||||||
|
self.assertEqual(guard.kind, "restart_uncertain")
|
||||||
|
first_deadline = guard.review_after
|
||||||
|
self.clock.value += 10
|
||||||
|
repeated = store.mark_restart_uncertain()
|
||||||
|
self.assertEqual(repeated.guard_id, guard.guard_id)
|
||||||
|
self.assertEqual(repeated.review_after, first_deadline)
|
||||||
|
|
||||||
|
def test_restart_converts_pre_evidence_pending_guard_for_admin_review(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
self._ready_slot(store)
|
||||||
|
store.protect_slot(1, "pending_play", guard_id="published-before-crash")
|
||||||
|
guard = store.mark_restart_uncertain()
|
||||||
|
self.assertEqual(guard.guard_id, "published-before-crash")
|
||||||
|
self.assertEqual(guard.kind, "restart_uncertain")
|
||||||
|
self.assertEqual(store.player_observation().active_slot, 1)
|
||||||
|
self.assertTrue(
|
||||||
|
store.clear_guard(
|
||||||
|
1,
|
||||||
|
guard.guard_id,
|
||||||
|
actor="operator",
|
||||||
|
reason="Player replaced",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_guard_review_transition_is_reported_only_once(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
self._ready_slot(store)
|
||||||
|
store.protect_slot(
|
||||||
|
1,
|
||||||
|
"restart_uncertain",
|
||||||
|
guard_id="one-warning",
|
||||||
|
review_after=self.clock.value,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(store.mark_due_guards_for_review()), 1)
|
||||||
|
self.assertEqual(store.mark_due_guards_for_review(), [])
|
||||||
|
|
||||||
|
def test_clearing_one_legacy_guard_does_not_erase_other_observation(self):
|
||||||
|
with self.open_store() as store:
|
||||||
|
self._ready_slot(store)
|
||||||
|
second = store.reserve_download(
|
||||||
|
"https://example.test/second", None, None, job_id="second"
|
||||||
|
)
|
||||||
|
store.mark_download_running("second", 2, second.generation)
|
||||||
|
store.complete_download(
|
||||||
|
"second",
|
||||||
|
2,
|
||||||
|
second.generation,
|
||||||
|
title="Second",
|
||||||
|
items=(("Second", "second.mp4"),),
|
||||||
|
)
|
||||||
|
first_guard = store.protect_slot(
|
||||||
|
1, "legacy_uncertain", guard_id="legacy-one"
|
||||||
|
)
|
||||||
|
store.protect_slot(2, "player_active", guard_id="active-two")
|
||||||
|
store.set_player_observation(
|
||||||
|
availability="online",
|
||||||
|
phase="playing",
|
||||||
|
active_slot=2,
|
||||||
|
active_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||||
|
paused=False,
|
||||||
|
reconciliation_state="confirmed",
|
||||||
|
reconciliation_due_at=None,
|
||||||
|
reconciliation_id=None,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
store.clear_guard(
|
||||||
|
1,
|
||||||
|
first_guard.guard_id,
|
||||||
|
actor="operator",
|
||||||
|
reason="legacy evidence resolved",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
observation = store.player_observation()
|
||||||
|
self.assertEqual(observation.active_slot, 2)
|
||||||
|
self.assertEqual(observation.phase, "playing")
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyMigrationTests(StoreTestCase):
|
||||||
|
def write_legacy(self, data):
|
||||||
|
self.data.mkdir(parents=True, exist_ok=True)
|
||||||
|
raw = yaml.safe_dump(data, sort_keys=True).encode("utf-8")
|
||||||
|
self.config.legacy_yaml_path.write_bytes(raw)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def test_migration_preserves_holes_timestamps_original_and_backup(self):
|
||||||
|
raw = self.write_legacy(
|
||||||
|
{
|
||||||
|
"slots": {
|
||||||
|
"2": {
|
||||||
|
"slot": 2,
|
||||||
|
"url": "https://example.test/two",
|
||||||
|
"yt_id": "abc",
|
||||||
|
"title": "Two",
|
||||||
|
"path": "/unsafe/ignored",
|
||||||
|
"m3u": "/unsafe/ignored/playlist.m3u",
|
||||||
|
"created_at": "2025-01-02T03:04:05Z",
|
||||||
|
"expires_at": "2025-01-04T03:04:05Z",
|
||||||
|
"item_count": 3,
|
||||||
|
"state": "playing",
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"slot": 7,
|
||||||
|
"url": "https://example.test/seven",
|
||||||
|
"title": "Seven",
|
||||||
|
"path": "/unsafe/seven",
|
||||||
|
"m3u": "/unsafe/seven/playlist.m3u",
|
||||||
|
"created_at": "2025-02-01T00:00:00Z",
|
||||||
|
"expires_at": "2025-02-03T00:00:00Z",
|
||||||
|
"item_count": 1,
|
||||||
|
"state": "ready",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
with self.open_store() as store:
|
||||||
|
self.assertTrue(store.migration_report.migrated)
|
||||||
|
self.assertEqual(store.migration_report.source_sha256, digest)
|
||||||
|
self.assertEqual(store.get_slot(1).media_state, "empty")
|
||||||
|
self.assertEqual(store.get_slot(2).title, "Two")
|
||||||
|
self.assertEqual(store.get_slot(7).slot, 7)
|
||||||
|
self.assertEqual(store.get_slot(2).created_at, 1_735_787_045.0)
|
||||||
|
guards = store.list_live_guards()
|
||||||
|
self.assertEqual([(guard.slot, guard.state) for guard in guards], [(2, "review_required")])
|
||||||
|
marker = store.connection.execute(
|
||||||
|
"SELECT * FROM data_migrations WHERE name = ?",
|
||||||
|
(LEGACY_MIGRATION_NAME,),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(marker["source_sha256"], digest)
|
||||||
|
backup = Path(marker["backup_path"])
|
||||||
|
self.assertEqual(backup.read_bytes(), raw)
|
||||||
|
self.assertEqual(self.config.legacy_yaml_path.read_bytes(), raw)
|
||||||
|
|
||||||
|
def test_completed_migration_is_idempotent_and_ignores_changed_yaml(self):
|
||||||
|
original = self.write_legacy(
|
||||||
|
{
|
||||||
|
"slots": {
|
||||||
|
"3": {
|
||||||
|
"slot": 3,
|
||||||
|
"url": "https://example.test/original",
|
||||||
|
"title": "Original",
|
||||||
|
"path": "/ignored",
|
||||||
|
"m3u": "/ignored/playlist.m3u",
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"expires_at": "2025-01-03T00:00:00Z",
|
||||||
|
"item_count": 1,
|
||||||
|
"state": "ready",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with self.open_store() as store:
|
||||||
|
generation = store.get_slot(3).generation
|
||||||
|
backup_path = Path(store.migration_report.backup_path)
|
||||||
|
self.config.legacy_yaml_path.write_text("not: [the, original]\n", encoding="utf-8")
|
||||||
|
with self.open_store() as store:
|
||||||
|
self.assertFalse(store.migration_report.migrated)
|
||||||
|
self.assertEqual(store.get_slot(3).title, "Original")
|
||||||
|
self.assertEqual(store.get_slot(3).generation, generation)
|
||||||
|
self.assertEqual(backup_path.read_bytes(), original)
|
||||||
|
|
||||||
|
def test_failed_migration_keeps_original_and_verified_backup_recoverable(self):
|
||||||
|
self.data.mkdir(parents=True, exist_ok=True)
|
||||||
|
raw = b"slots:\n 1: [truncated\n"
|
||||||
|
self.config.legacy_yaml_path.write_bytes(raw)
|
||||||
|
store = ControllerStore(self.config, now=self.clock)
|
||||||
|
with self.assertRaises(MigrationError):
|
||||||
|
store.open()
|
||||||
|
self.assertFalse(self.config.database_path.exists())
|
||||||
|
self.assertEqual(self.config.legacy_yaml_path.read_bytes(), raw)
|
||||||
|
backups = list(self.data.glob("playlists.yaml.pre-v2.*.bak"))
|
||||||
|
self.assertEqual(len(backups), 1)
|
||||||
|
self.assertEqual(backups[0].read_bytes(), raw)
|
||||||
|
|
||||||
|
def test_normalized_duplicate_slot_keys_fail_all_or_nothing(self):
|
||||||
|
self.data.mkdir(parents=True, exist_ok=True)
|
||||||
|
raw = b"slots:\n 1:\n slot: 1\n '01':\n slot: 1\n"
|
||||||
|
self.config.legacy_yaml_path.write_bytes(raw)
|
||||||
|
with self.assertRaisesRegex(MigrationError, "normalize to 1"):
|
||||||
|
self.open_store()
|
||||||
|
self.assertFalse(self.config.database_path.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Production WSGI entry point and Controller service lifecycle owner."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from controller.config import load_config
|
||||||
|
from controller.service import ControllerService
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
service = ControllerService(config)
|
||||||
|
application = create_app(config, service)
|
||||||
|
service.start()
|
||||||
|
atexit.register(service.stop)
|
||||||
Reference in New Issue
Block a user