feat: overhaul video player controller
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user