9 Commits
33 changed files with 12270 additions and 902 deletions
+21
View File
@@ -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/
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
nodejs \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# yt-dlp requires Node 22+ for current YouTube JavaScript challenges. Copy
# only the runtime binary into the smaller Python/ffmpeg application image.
FROM node:22.23.1-bookworm-slim AS javascript-runtime
FROM python:3.12-slim-bookworm
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
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt ./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)
RUN python -m pip install --no-cache-dir -U yt-dlp
COPY --chown=controller:controller controller ./controller
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)
# 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
USER controller:controller
COPY app.py ./app.py
COPY controller_core.py ./controller_core.py
EXPOSE 7531
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 ["python", "app.py"]
CMD ["gunicorn", "--config", "gunicorn.conf.py", "wsgi:application"]
+975
View File
@@ -0,0 +1,975 @@
# 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 exact
in-flight echo is recognized; 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. URL validation checks only the `http`/`https`
shape; 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.
+119 -24
View File
@@ -1,10 +1,17 @@
from flask import Flask, render_template_string, request, redirect, url_for, flash
from __future__ import annotations
import logging
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__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret")
from controller.config import ControllerConfig, load_config
from controller.models import StoreError
from controller.service import ControllerService, ControllerUnavailableError
LOG = logging.getLogger(__name__)
HTML_FORM = """
<!doctype html>
@@ -217,37 +224,125 @@ HTML_FORM = """
</html>
"""
@app.route("/", methods=["GET"])
def _valid_media_url(value: str) -> bool:
try:
parsed = urlsplit(value)
except ValueError:
return False
return parsed.scheme.lower() in {"http", "https"} and bool(parsed.netloc)
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",
)
web_app.extensions["controller_service"] = service
web_app.extensions["controller_config"] = config
@web_app.get("/")
def index():
return render_template_string(HTML_FORM)
@app.route("/submit", methods=["POST"])
@web_app.post("/submit")
def submit():
url = request.form.get("url", "").strip()
submitter = request.form.get("submitter", "").strip() or None
note = request.form.get("note", "").strip() or None
if not url.startswith("http"):
flash("Invalid URL.")
if not _valid_media_url(url):
flash("Invalid URL. Use a complete http:// or https:// address.")
return redirect(url_for("index"))
result = enqueue_download(url, submitter, note)
if result.get("status") == "queue_full":
secs = result.get("oldest_expires_in_seconds", 0)
hours = max(1, secs // 3600)
flash(f"Queue is full. Oldest playlist expires in about {hours} hour(s).")
try:
result = service.enqueue_download(url, submitter, note)
except ControllerUnavailableError as exc:
LOG.warning("playlist submission was rejected: %s", exc)
flash("The Controller is unavailable; please try again shortly.")
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:
slot = result.get("slot")
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)
flash(_submission_message(result))
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__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7531)))
_run_development_server()
+55 -7
View File
@@ -3,13 +3,61 @@ mqtt:
port: 1883
username: 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
topics:
cmd: "youtube/player/cmd"
status: "youtube/player/status"
player_command: youtube/player/cmd
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:
display: ":0"
xdg_runtime_dir: "/run/user/1000"
mpv_binary: "/usr/bin/mpv"
storage:
media_base: /mnt/Media/YTPlayer
data_base: /controller-data
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
+5
View File
@@ -0,0 +1,5 @@
"""YTPlayer Controller v2 package."""
from .config import ControllerConfig, ConfigError, load_config
__all__ = ["ControllerConfig", "ConfigError", "load_config"]
+308
View File
@@ -0,0 +1,308 @@
"""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
@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,
),
)
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,
)
+48
View File
@@ -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
+711
View File
@@ -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)
+98
View File
@@ -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
+141
View File
@@ -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."""
+522
View File
@@ -0,0 +1,522 @@
"""Resilient MQTT transport with explicit Player command guarantees."""
from __future__ import annotations
import json
import logging
import queue
import threading
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,
):
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._router_echo_lock = threading.Lock()
self._pending_router_echoes: dict[str, bytes] = {}
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.
# The marker is removed as soon as the QoS 1 publish call completes;
# this intentionally prefers a false ownership loss over mistaking a
# later same-value operator command for a Controller echo.
echo_token = uuid.uuid4().hex
with self._router_echo_lock:
self._pending_router_echoes[echo_token] = mode.encode("utf-8")
try:
return 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,
)
finally:
with self._router_echo_lock:
self._pending_router_echoes.pop(echo_token, None)
def _claim_router_echo(self, payload: bytes) -> bool:
"""Claim one exact mode-set echo from an in-flight local publish."""
with self._router_echo_lock:
for token, expected in tuple(self._pending_router_echoes.items()):
if expected == payload:
del self._pending_router_echoes[token]
return True
return False
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,
)
+899
View File
@@ -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
+422
View File
@@ -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
+2224
View File
File diff suppressed because it is too large Load Diff
+63 -817
View File
@@ -1,838 +1,84 @@
import os
import time
import json
"""Deprecated, lazy compatibility facade for the Controller v2 service.
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 subprocess
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
from typing import Any
import yaml
import paho.mqtt.client as mqtt
from controller.config import ControllerConfig, load_config
from controller.service import ControllerService, ControllerUnavailableError
# --- Config / paths ---
MEDIA_BASE = Path(os.environ.get("YTPLAYER_MEDIA_BASE", "/mnt/Media/YTPlayer"))
DATA_BASE = Path(os.environ.get("YTPLAYER_DATA_BASE", "/controller-data"))
STATE_PATH = DATA_BASE / "playlists.yaml"
_lifecycle_lock = threading.Lock()
_service: ControllerService | None = None
_config: ControllerConfig | None = None
MAX_SLOTS = int(os.environ.get("YTPLAYER_MAX_SLOTS", "10"))
RETENTION_SECONDS = int(os.environ.get("YTPLAYER_RETENTION_SECONDS", str(48 * 3600)))
# Kept as transition indicators for integrations that inspected the old module.
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"
TOPIC_PLAYBACK_BASE = "youtube/media/playback" # e.g. youtube/media/playback/1/title
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
def start_controller() -> None:
"""Construct and start the singleton compatibility service explicitly."""
MEDIA_BASE.mkdir(parents=True, exist_ok=True)
DATA_BASE.mkdir(parents=True, exist_ok=True)
# --- 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:
global _config, _service, mqtt_client, mqtt_started
with _lifecycle_lock:
if _service is not None and _service.started:
return
state_str = data.get("state")
slot_val = data.get("slot")
if not state_str or slot_val is None:
return
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}")
config = load_config()
service = ControllerService(config)
service.start()
_config = config
_service = service
mqtt_client = service.mqtt
mqtt_started = True
def mqtt_publish(topic: str, payload, **kwargs):
"""
Small wrapper that JSON-encodes non-strings and prints what we publish.
Keeps logs useful and avoids repeating code.
"""
if not isinstance(payload, str):
payload = json.dumps(payload)
print(f"[mqtt] publish {topic} -> {payload}")
mqtt_client.publish(topic, payload, **kwargs)
def stop_controller() -> None:
"""Stop and discard the singleton compatibility service."""
global _config, _service, mqtt_client, mqtt_started
with _lifecycle_lock:
service = _service
if service is not None:
service.stop()
_service = None
_config = None
mqtt_client = None
mqtt_started = False
# --- State model ---
def get_service() -> ControllerService:
"""Return the explicitly started compatibility service."""
@dataclass
class PlaylistSlot:
slot: int
url: str
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"),
with _lifecycle_lock:
if _service is None or not _service.started:
raise ControllerUnavailableError(
"Controller is not started; call start_controller() first"
)
return _service
class ControllerState:
def __init__(self):
self.slots: Dict[int, PlaylistSlot] = {}
def enqueue_download(
url: str,
submitter: str | None = None,
note: str | None = None,
) -> dict[str, Any]:
"""Queue a download through Controller v2."""
def load(self):
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
return get_service().enqueue_download(url, submitter, note)
state = ControllerState()
state.load()
state.prune_expired()
# --- Helpers ---
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"}
__all__ = [
"enqueue_download",
"get_service",
"mqtt_client",
"mqtt_started",
"start_controller",
"stop_controller",
]
)
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
View File
@@ -1,22 +1,64 @@
services:
controller:
build: .
build:
context: .
args:
CONTROLLER_UID: ${CONTROLLER_UID:-1000}
CONTROLLER_GID: ${CONTROLLER_GID:-1000}
image: video-player-controller:local
container_name: yt-controller
restart: unless-stopped
init: true
read_only: true
ports:
- "7531:7531"
- "${CONTROLLER_WEB_PORT:-7531}:7531"
environment:
- FLASK_SECRET_KEY=change-me
- MQTT_HOST=10.40.0.2
- MQTT_PORT=1883
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:?Set FLASK_SECRET_KEY in .env}
MQTT_HOST: ${MQTT_HOST:-10.40.0.2}
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:
- /mnt/Media:/mnt/Media # same path inside container
- /home/media/Scripts/Video-Player:/controller-data
- /home/media/Scripts/Video-Player/cookies.txt:/cookies_ro.txt:ro
- ${CONTROLLER_DATA_DIR:?Set CONTROLLER_DATA_DIR in .env}:/controller-data
- ${YTPLAYER_MEDIA_DIR:-/mnt/Media/YTPlayer}:/mnt/Media/YTPlayer
- ./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:
- tabernet
networks:
tabernet:
external: true
+30
View File
@@ -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
View File
@@ -1,4 +1,14 @@
flask
paho-mqtt
yt-dlp
pyyaml
Flask==3.1.3
gunicorn==26.0.0
paho-mqtt==2.1.0
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
+1
View File
@@ -0,0 +1 @@
"""Controller regression tests."""
+316
View File
@@ -0,0 +1,316 @@
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://example.com/playlist", **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_accepts_only_http_or_https_urls_with_a_network_location(self):
invalid_urls = (
"",
"http",
"https:///missing-host",
"httpfoo://example.com/video",
"ftp://example.com/video",
"javascript:alert(1)",
"/relative/path",
)
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, [])
for url in ("http://example.com/video", "https://example.com/video"):
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://example.com/playlist",
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://example.com/playlist", "Alice", "Before service")],
)
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()
+190
View File
@@ -0,0 +1,190 @@
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)
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_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()
+475
View File
@@ -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()
+443
View File
@@ -0,0 +1,443 @@
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 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_only_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)
self.client.publish_hook = None
self.adapter._on_message(
self.client,
None,
SimpleNamespace(
topic="audio/player/mode/set",
payload=b"video",
qos=1,
retain=False,
dup=False,
mid=32,
),
)
self.assertFalse(self.events.get_nowait().controller_echo)
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()
+515
View File
@@ -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()
+316
View File
@@ -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()
+314
View File
@@ -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()
+989
View File
@@ -0,0 +1,989 @@
import json
import queue
import tempfile
import threading
import time
import unittest
from dataclasses import replace
from pathlib import Path
from controller.config import (
ControllerConfig,
HomeAssistantConfig,
RouterConfig,
StorageConfig,
)
from controller.downloads import canonical_playlist_path
from controller.models import SlotProtectedError, StaleOperationError
from controller.mqtt_adapter import MqttEvent, PublishReceipt
from controller.service import ControllerService, ControllerUnavailableError
from controller.store import ControllerStore
class MutableClock:
def __init__(self, value):
self.value = value
def __call__(self):
return self.value
class FakeMqttAdapter:
"""In-memory transport boundary; it never starts a network client."""
def __init__(self, *, connected=True):
self.connected = connected
self.started = False
self.player_commands = []
self.router_modes = []
self.json_messages = []
self.legacy_messages = []
self.acknowledged = []
def start(self):
self.started = True
def stop(self):
self.started = False
def _receipt(self):
if self.connected:
return PublishReceipt(True, message_id=1)
return PublishReceipt(False, reason="MQTT is disconnected")
def publish_player_command(self, payload):
receipt = self._receipt()
if receipt.accepted:
self.player_commands.append(dict(payload))
return receipt
def publish_router_mode(self, mode):
receipt = self._receipt()
if receipt.accepted:
self.router_modes.append(mode)
return receipt
def publish_json(self, topic, payload, *, qos=0, retain=False):
receipt = self._receipt()
if receipt.accepted:
self.json_messages.append((topic, dict(payload), qos, retain))
return receipt
def publish_legacy(self, topic, payload, *, retain=True):
receipt = self._receipt()
if receipt.accepted:
self.legacy_messages.append((topic, payload, retain))
return receipt
def acknowledge(self, event):
self.acknowledged.append(event)
return True
class FakeDownloadManager:
def __init__(self, store=None):
self.store = store
self.recovered = False
self.recovery_calls = 0
self.expiry_calls = 0
self.stopped = False
self.delete_requests = []
def recover_pending_deletions(self):
self.recovered = True
def schedule_pending_deletion_recovery(self):
self.recovered = True
self.recovery_calls += 1
return True
def schedule_expired_deletions(self):
self.expiry_calls += 1
return []
def schedule_delete_slot(self, slot, *, reason, expected_url=None):
self.delete_requests.append((slot, reason, expected_url))
if self.store is None:
return None
return self.store.begin_delete(
slot,
reason=reason,
expected_url=expected_url,
)
def shutdown(self, *, wait):
self.stopped = wait
class ControllerServiceTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
base = Path(self.tempdir.name)
self.wall = MutableClock(1_700_000_000.0)
self.monotonic = MutableClock(100.0)
self.config = ControllerConfig(
storage=StorageConfig(
media_base=base / "media",
data_base=base / "data",
retention_seconds=3600,
expiry_interval_seconds=3600,
uncertain_review_after_seconds=30,
),
router=RouterConfig(
required_mode="video",
confirmation_timeout_seconds=3,
when_state_unknown="continue",
continue_on_confirmation_failure=True,
fallback_restore_mode=None,
),
homeassistant=HomeAssistantConfig(enabled=False),
)
self.events = queue.Queue()
self.store = None
self.mqtt = None
self.downloads = None
self.service = None
def tearDown(self):
if self.service is not None and self.service.started:
self.service.stop()
self.tempdir.cleanup()
def start_service(self, *, config=None, mqtt=None):
config = config or self.config
self.events = queue.Queue()
self.store = ControllerStore(config.storage, now=self.wall)
self.mqtt = mqtt or FakeMqttAdapter()
self.downloads = FakeDownloadManager(self.store)
self.service = ControllerService(
config,
store=self.store,
mqtt_adapter=self.mqtt,
event_queue=self.events,
download_manager=self.downloads,
monotonic=self.monotonic,
wall_clock=self.wall,
)
self.service.start()
self.assertFalse(self.downloads.recovered)
return self.service
def ready_slot(self, slot=1):
reservation = self.store.reserve_download(
f"https://example.test/{slot}",
"tester",
"service test",
job_id=f"ready-{slot}",
)
self.assertEqual(reservation.slot, slot)
self.assertTrue(
self.store.mark_download_running(
reservation.job_id,
reservation.slot,
reservation.generation,
)
)
self.assertTrue(
self.store.complete_download(
reservation.job_id,
reservation.slot,
reservation.generation,
title=f"Slot {slot}",
items=(("One", "one.mp4"),),
)
)
playlist = canonical_playlist_path(self.service.config.storage.media_base, slot)
playlist.parent.mkdir(parents=True, exist_ok=True)
playlist.write_text("one.mp4\n", encoding="utf-8")
return playlist
def send_message(
self,
topic,
payload,
*,
qos=0,
retain=False,
message_id=None,
duplicate=False,
controller_echo=False,
):
if not isinstance(payload, bytes):
payload = (
payload.encode("utf-8")
if isinstance(payload, str)
else json.dumps(payload).encode("utf-8")
)
self.events.put(
MqttEvent(
kind="message",
topic=topic,
payload=payload,
qos=qos,
retain=retain,
duplicate=duplicate,
message_id=message_id,
connection_generation=1,
controller_echo=controller_echo,
)
)
def wait_for(self, predicate, message):
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
self.fail(message)
def result_events(self):
return [
payload
for topic, payload, qos, retain in self.mqtt.json_messages
if topic == self.service.config.topics.controller_result
]
def test_play_waits_for_router_confirmation_and_player_evidence(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
playlist = self.ready_slot(1)
self.send_message(config.topics.router_mode_state, "service", qos=1)
self.wait_for(
lambda: self.store.router_observation().mode == "service",
"Router state was not consumed",
)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["status"], "pending")
self.assertEqual(result["router"], "waiting")
self.assertEqual(self.mqtt.router_modes, ["video"])
self.assertEqual(self.mqtt.player_commands, [])
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
self.send_message(
config.topics.router_mode_set,
"video",
qos=1,
controller_echo=True,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 2,
"Controller Router command echo was not consumed",
)
self.assertEqual(self.store.live_audio_lease().state, "acquiring")
self.assertEqual(self.mqtt.player_commands, [])
self.send_message(config.topics.router_mode_state, "video", qos=1)
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"Player command was not released after Router confirmation",
)
self.assertEqual(
self.mqtt.player_commands,
[{"action": "play", "slot": 1, "label": "Slot 1"}],
)
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
self.send_message(
config.topics.player_status,
{"state": "playing", "slot": 1, "path": str(playlist)},
qos=1,
)
self.wait_for(
lambda: self.store.player_observation().phase == "playing",
"authoritative Player status was not reduced",
)
self.assertEqual(self.service.playback.projected_slot_state(1), "playing")
self.assertIn(
"player_operation_confirmed",
{event["type"] for event in self.result_events()},
)
def test_manual_router_set_during_acquisition_resolves_by_failure_policy(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
self.ready_slot(1)
self.send_message(config.topics.router_mode_state, "service", qos=1)
self.wait_for(
lambda: self.store.router_observation().mode == "service",
"Router state was not consumed",
)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["router"], "waiting")
self.send_message(config.topics.router_mode_set, "announcements", qos=1)
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"manual Router intent did not resolve acquisition",
)
lease = self.store.get_audio_lease(self.service._session_lease_id)
self.assertIsNotNone(lease)
self.assertEqual(lease.state, "unconfirmed")
warnings = [
event
for event in self.result_events()
if event["type"] == "audio_router_warning"
]
self.assertTrue(warnings)
self.assertIs(warnings[-1]["continued"], True)
def test_pause_state_remains_telemetry_authoritative(self):
self.start_service()
playlist = self.ready_slot(1)
self.service.submit_control({"action": "play", "slot": 1})
self.wait_for(
lambda: self.mqtt.player_commands == [
{"action": "play", "slot": 1, "label": "Slot 1"}
],
"play command was not published",
)
self.send_message(
self.config.topics.player_status,
{"state": "playing", "slot": 1, "path": str(playlist)},
qos=1,
)
self.wait_for(
lambda: self.service.playback.pending is None,
"play operation was not confirmed",
)
playing_generation = self.store.player_observation().generation
self.service.submit_control({"action": "pause"})
self.send_message(
self.config.topics.player_status,
{"state": "paused", "slot": 1, "path": str(playlist)},
qos=1,
)
self.wait_for(
lambda: self.store.player_observation().generation > playing_generation,
"pause status was not consumed",
)
self.assertIsNotNone(self.service.playback.pending)
self.assertEqual(self.service.playback.projected_slot_state(1), "playing")
self.send_message(
self.config.topics.player_telemetry,
{
"state": "telemetry",
"slot": 1,
"path": str(playlist),
"paused": True,
},
)
self.wait_for(
lambda: self.service.playback.pending is None,
"pause was not confirmed by telemetry",
)
self.assertEqual(self.store.player_observation().paused, True)
self.assertEqual(self.service.playback.projected_slot_state(1), "paused")
self.assertEqual(self.mqtt.player_commands[-1], {"action": "pause"})
def test_retained_controller_command_is_rejected_then_acked_and_reported(self):
self.start_service()
self.send_message(
self.config.topics.controller_command,
{"action": "play", "path": "/must/not/run"},
qos=1,
retain=True,
message_id=42,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 1,
"retained command was not acknowledged after rejection",
)
self.assertEqual(self.mqtt.player_commands, [])
event = self.result_events()[-1]
self.assertEqual(event["version"], 2)
self.assertEqual(event["type"], "retained_command_rejected")
topic, _, qos, retain = self.mqtt.json_messages[-1]
self.assertEqual(topic, "youtube/controller/v2/events")
self.assertEqual((qos, retain), (1, False))
self.assertEqual(self.mqtt.acknowledged[0].message_id, 42)
def test_retained_detailed_player_evidence_cannot_clear_restart_protection(self):
self.start_service()
self.store.protect_slot(
1,
"restart_uncertain",
guard_id="restart-review",
)
self.send_message(
self.config.topics.player_status,
{"state": "idle", "slot": 1, "reason": "stop"},
qos=1,
retain=True,
message_id=41,
)
self.send_message(
self.config.topics.player_telemetry,
{
"state": "telemetry",
"slot": 1,
"path": None,
"paused": False,
},
retain=True,
)
self.wait_for(
lambda: any(event.message_id == 41 for event in self.mqtt.acknowledged),
"retained QoS 1 status was not deliberately acknowledged",
)
self.wait_for(
lambda: len(
[
event
for event in self.result_events()
if event["type"] == "retained_player_evidence_rejected"
]
)
== 2,
"retained telemetry rejection was not reported",
)
guards = self.store.list_live_guards()
self.assertEqual(len(guards), 1)
self.assertEqual(guards[0].guard_id, "restart-review")
self.assertIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
retained_warnings = [
event
for event in self.result_events()
if event["type"] == "retained_player_evidence_rejected"
]
self.assertEqual(len(retained_warnings), 2)
def test_optional_result_topic_can_be_disabled_without_affecting_ack(self):
config = replace(
self.config,
topics=replace(self.config.topics, controller_result=None),
)
self.start_service(config=config)
self.send_message(
config.topics.controller_command,
{"action": "delete_slot", "slot": 1},
qos=1,
retain=True,
message_id=7,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 1,
"command was not acknowledged with result topic disabled",
)
self.assertEqual(self.mqtt.json_messages, [])
def test_broker_unavailable_does_not_prevent_service_startup(self):
mqtt = FakeMqttAdapter(connected=False)
self.start_service(mqtt=mqtt)
health = self.service.health()
self.assertTrue(self.service.started)
self.assertTrue(mqtt.started)
self.assertTrue(health["coordinator_alive"])
self.assertEqual(health["status"], "degraded")
self.assertIn("mqtt_disconnected", health["degraded_reasons"])
self.assertIn("awaiting_player_evidence", health["degraded_reasons"])
def test_cleanup_and_downloads_wait_for_post_start_player_evidence(self):
self.start_service()
self.service._next_expiry = self.monotonic()
time.sleep(0.2)
self.assertEqual(self.downloads.expiry_calls, 0)
self.assertFalse(self.downloads.recovered)
with self.assertRaisesRegex(
ControllerUnavailableError,
"deferred until fresh Player evidence",
):
self.service.enqueue_download(
"https://example.test/deferred",
None,
None,
)
self.send_message(
self.config.topics.player_state,
"idle",
retain=True,
)
time.sleep(0.2)
self.assertFalse(self.downloads.recovered)
self.assertEqual(self.downloads.expiry_calls, 0)
self.assertIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
self.send_message(
self.config.topics.player_status,
{"state": "idle", "message": "player online"},
qos=1,
)
self.wait_for(
lambda: self.downloads.recovered,
"detailed Player idle did not release deferred recovery",
)
self.wait_for(
lambda: self.downloads.expiry_calls > 0,
"detailed Player idle did not release deferred expiry",
)
self.assertNotIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
def test_administrator_can_explicitly_unlock_cleanup_without_player_evidence(self):
self.start_service()
review = self.service.health()["cleanup_reconciliation"]
self.assertIsNotNone(review)
result = self.service.submit_control(
{
"action": "reconcile_playback",
"scope": "cleanup",
"decision": "clear",
"confirm": True,
"review_id": review["review_id"],
"actor": "operator",
"reason": "Player appliance was independently verified offline",
}
)
self.assertEqual(
result,
{
"status": "cleared",
"review_id": review["review_id"],
"scope": "cleanup",
},
)
self.wait_for(
lambda: self.downloads.recovered,
"administrative cleanup override did not schedule recovery",
)
health = self.service.health()
self.assertIsNone(health["cleanup_reconciliation"])
self.assertNotIn("awaiting_player_evidence", health["degraded_reasons"])
def test_cleanup_reconciliation_token_rotates_and_cannot_bypass_a_guard(self):
self.start_service()
first = self.service.health()["cleanup_reconciliation"]["review_id"]
self.events.put(MqttEvent(kind="connected", connection_generation=1))
self.wait_for(
lambda: (
self.service.health()["cleanup_reconciliation"] is not None
and self.service.health()["cleanup_reconciliation"]["review_id"]
!= first
),
"cleanup reconciliation token did not rotate on MQTT connect",
)
current = self.service.health()["cleanup_reconciliation"]["review_id"]
with self.assertRaisesRegex(StaleOperationError, "review_id is stale"):
self.service.submit_control(
{
"action": "reconcile_playback",
"scope": "cleanup",
"decision": "clear",
"confirm": True,
"review_id": first,
"actor": "operator",
"reason": "stale request",
}
)
self.store.protect_slot(1, "restart_uncertain", guard_id="late-guard")
with self.assertRaisesRegex(SlotProtectedError, "known playback protection"):
self.service.submit_control(
{
"action": "reconcile_playback",
"scope": "cleanup",
"decision": "clear",
"confirm": True,
"review_id": current,
"actor": "operator",
"reason": "must not bypass a guard",
}
)
def test_manual_delete_requires_explicit_override_while_player_is_unknown(self):
self.start_service()
self.ready_slot(1)
with self.assertRaisesRegex(
SlotProtectedError,
"confirm_player_unknown=true",
):
self.service.submit_control({"action": "delete_slot", "slot": 1})
def test_administrator_can_clear_exact_uncertain_playback_review(self):
self.start_service()
playlist = self.ready_slot(1)
self.store.set_player_observation(
availability="unknown",
phase="unknown",
active_slot=1,
active_path=str(playlist),
paused=None,
reconciliation_state="operator_review",
reconciliation_due_at=None,
reconciliation_id="review-token",
)
guard = self.store.protect_slot(
1,
"restart_uncertain",
guard_id="review-token",
)
review = self.service.health()["reconciliation_reviews"]
self.assertEqual(review[0]["review_id"], "review-token")
result = self.service.submit_control(
{
"action": "reconcile_playback",
"decision": "clear",
"confirm": True,
"slot": 1,
"review_id": review[0]["review_id"],
"actor": "operator",
"reason": "Player host was replaced",
}
)
self.assertEqual(
result,
{"status": "cleared", "slot": 1, "review_id": "review-token"},
)
self.assertEqual(self.store.list_live_guards(), [])
observation = self.store.player_observation()
self.assertEqual(observation.reconciliation_state, "admin_cleared")
self.assertIsNone(observation.active_slot)
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
self.assertEqual(self.result_events()[-1]["type"], "playback_reconciled")
def test_busy_player_keeps_video_route_until_actual_playback_finishes(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
self.ready_slot(1)
second_playlist = self.ready_slot(2)
self.send_message(config.topics.router_mode_state, "service", qos=1)
self.wait_for(
lambda: self.store.router_observation().mode == "service",
"Router service mode was not observed",
)
self.service.submit_control({"action": "play", "slot": 1})
self.send_message(config.topics.router_mode_state, "video", qos=1)
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"play command was not released",
)
self.send_message(
config.topics.player_status,
{
"state": "busy",
"message": "mpv already playing; stop first",
},
qos=1,
)
self.wait_for(
lambda: self.service.playback.pending is None,
"busy response was not reduced",
)
observation = self.store.player_observation()
self.assertIsNone(observation.active_slot)
self.assertIsNone(observation.active_path)
self.assertEqual(observation.phase, "unknown")
self.assertIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
self.assertEqual(self.mqtt.router_modes, ["video"])
lease = self.store.live_audio_lease()
self.assertIsNotNone(lease)
self.assertEqual(lease.state, "held")
self.send_message(
config.topics.player_status,
{
"state": "playing",
"slot": 2,
"path": str(second_playlist),
},
qos=1,
)
self.wait_for(
lambda: self.store.player_observation().active_slot == 2,
"Player did not reassert its actual playback identity",
)
self.assertEqual(self.store.live_audio_lease().state, "held")
def test_disconnected_synchronous_play_failure_is_not_reported_pending(self):
self.start_service(mqtt=FakeMqttAdapter(connected=False))
self.ready_slot(1)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["status"], "unavailable")
self.assertIsNone(self.service.playback.pending)
self.assertEqual(self.store.list_live_guards(), [])
def test_router_timeout_uses_monotonic_clock_and_continues_with_warning(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
self.ready_slot(1)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["router"], "waiting")
self.wall.value -= 10_000
self.monotonic.value += 4
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"Router timeout did not release play within its finite deadline",
)
warnings = [
event
for event in self.result_events()
if event["type"] == "audio_router_warning"
]
self.assertTrue(warnings)
self.assertIs(warnings[-1]["continued"], True)
def test_duplicate_toggle_is_suppressed_and_translated_idempotently(self):
self.start_service()
playlist = self.ready_slot(1)
self.send_message(
self.config.topics.player_telemetry,
{
"state": "telemetry",
"slot": 1,
"path": str(playlist),
"paused": False,
},
)
self.wait_for(
lambda: self.store.player_observation().active_slot == 1,
"active telemetry was not consumed",
)
command = {"action": "toggle"}
self.send_message(
self.config.topics.controller_command,
command,
qos=1,
message_id=71,
)
self.send_message(
self.config.topics.controller_command,
command,
qos=1,
message_id=71,
duplicate=True,
)
self.wait_for(
lambda: sum(
event.message_id == 71 for event in self.mqtt.acknowledged
)
== 2,
"duplicate command packets were not both acknowledged",
)
self.assertEqual(self.mqtt.player_commands, [{"action": "pause"}])
def test_path_only_managed_play_adds_stable_slot_identity(self):
self.start_service()
playlist = self.ready_slot(1)
result = self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
self.assertEqual(result["status"], "pending")
self.assertEqual(
self.mqtt.player_commands,
[{"action": "play", "playlist": str(playlist), "slot": 1}],
)
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
def test_explicit_slot_cannot_be_paired_with_another_managed_slot_path(self):
self.start_service()
self.ready_slot(1)
second_playlist = self.ready_slot(2)
with self.assertRaisesRegex(ValueError, "conflicts"):
self.service.submit_control(
{
"action": "play",
"slot": 1,
"playlist": str(second_playlist),
}
)
self.assertEqual(self.mqtt.player_commands, [])
self.assertEqual(self.store.list_live_guards(), [])
def test_administrator_can_clear_external_playback_review_without_slot(self):
config = replace(
self.config,
topics=replace(self.config.topics, controller_result=None),
)
self.start_service(config=config)
observation = self.store.mark_external_uncertain(
"/srv/operator-playlists/special-event.m3u",
review_after=self.wall.value,
details={"reason": "restart"},
)
self.assertIsNotNone(observation.reconciliation_id)
reviews = self.service.health()["reconciliation_reviews"]
self.assertEqual(len(reviews), 1)
review_id = reviews[0]["review_id"]
result = self.service.submit_control(
{
"action": "reconcile_playback",
"decision": "clear",
"confirm": True,
"review_id": review_id,
"actor": "operator",
"reason": "Player appliance was replaced",
}
)
self.assertEqual(
result,
{"status": "cleared", "review_id": review_id},
)
cleared = self.store.player_observation()
self.assertIsNone(cleared.active_path)
self.assertEqual(cleared.reconciliation_state, "admin_cleared")
def test_external_play_intent_is_durable_immediately_after_puback(self):
self.start_service()
playlist = Path(self.tempdir.name) / "operator-playlist.m3u"
playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8")
result = self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
self.assertEqual(result["status"], "pending")
self.assertEqual(
self.mqtt.player_commands,
[{"action": "play", "playlist": str(playlist)}],
)
reviews = self.service.health()["reconciliation_reviews"]
self.assertEqual(len(reviews), 1)
review_id = reviews[0]["review_id"]
self.assertIs(reviews[0]["clearable"], False)
self.service.stop()
self.service = None
with ControllerStore(self.config.storage, now=self.wall).open() as reopened:
reopened.mark_restart_uncertain()
observation = reopened.player_observation()
self.assertEqual(observation.active_path, str(playlist))
self.assertEqual(observation.reconciliation_id, review_id)
def test_live_external_play_fence_cannot_be_admin_cleared(self):
self.start_service()
playlist = Path(self.tempdir.name) / "operator-playlist.m3u"
playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8")
self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
review = self.service.health()["reconciliation_reviews"][0]
self.assertIs(review["clearable"], False)
with self.assertRaisesRegex(ValueError, "Player operation is pending"):
self.service.submit_control(
{
"action": "reconcile_playback",
"decision": "clear",
"confirm": True,
"review_id": review["review_id"],
"actor": "operator",
"reason": "clear requested too early",
}
)
self.assertIsNotNone(self.service.playback.pending)
observation = self.store.player_observation()
self.assertEqual(observation.reconciliation_id, review["review_id"])
self.assertEqual(observation.active_path, str(playlist))
def test_router_gated_play_marks_all_reconciliation_reviews_not_clearable(self):
self.start_service()
self.ready_slot(1)
self.store.set_router_observation(mode="service", freshness="current")
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["router"], "waiting")
# Model a durable/reconnect review becoming visible while Router
# acquisition is pending.
self.store.protect_slot(
2,
"restart_uncertain",
guard_id="review-during-router-acquisition",
)
reviews = self.service.health()["reconciliation_reviews"]
self.assertEqual(len(reviews), 1)
self.assertIs(reviews[0]["clearable"], False)
def test_definite_external_publish_failure_clears_prepublication_guard(self):
self.start_service(mqtt=FakeMqttAdapter(connected=False))
playlist = Path(self.tempdir.name) / "operator-playlist.m3u"
playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8")
result = self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
self.assertEqual(result["status"], "unavailable")
self.assertEqual(self.service.health()["reconciliation_reviews"], [])
observation = self.store.player_observation()
self.assertIsNone(observation.active_path)
self.assertIsNone(observation.reconciliation_id)
def test_unexpected_reducer_failure_is_not_acked_or_deduplicated(self):
self.start_service()
original = self.service.playback.handle_status
failure_seen = threading.Event()
attempts = 0
def fail_once(payload):
nonlocal attempts
attempts += 1
if attempts == 1:
failure_seen.set()
raise RuntimeError("transient reducer failure")
return original(payload)
self.service.playback.handle_status = fail_once
payload = {
"state": "playing",
"slot": 1,
"path": str(self.config.storage.media_base / "slot1" / "playlist.m3u"),
}
self.send_message(
self.config.topics.player_status,
payload,
qos=1,
message_id=88,
)
self.assertTrue(failure_seen.wait(timeout=1))
time.sleep(0.05)
self.assertEqual(self.mqtt.acknowledged, [])
self.send_message(
self.config.topics.player_status,
payload,
qos=1,
message_id=88,
duplicate=True,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 1,
"retried status was not processed and acknowledged",
)
self.assertEqual(attempts, 2)
self.assertEqual(self.store.player_observation().phase, "playing")
if __name__ == "__main__":
unittest.main()
+556
View File
@@ -0,0 +1,556 @@
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_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()
+22
View File
@@ -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)