544 lines
16 KiB
Markdown
544 lines
16 KiB
Markdown
# YTPlayer Video Player
|
||
|
||
YTPlayer is an MQTT-controlled `mpv` endpoint intended to run continuously as
|
||
the system service `ytplayer-player.service`. A controller publishes JSON
|
||
commands to play a file, playlist, or numbered media slot. The player reports
|
||
status and telemetry over MQTT and sends all audio into one virtual PipeWire
|
||
sink for the separate Audio Router to place on HDMI, analog, or neither.
|
||
|
||
## What it does
|
||
|
||
- Plays a direct media path or an `.m3u` playlist with `mpv`.
|
||
- Maps numbered slots to `/mnt/Media/YTPlayer/slotN/playlist.m3u`.
|
||
- Supports play, stop, pause, resume, and pause-toggle commands.
|
||
- Allows only one owned `mpv` process at a time.
|
||
- Publishes JSON status, a retained flat state, and detailed playback telemetry.
|
||
- Reconnects to MQTT automatically without exiting when the broker or network
|
||
is unavailable.
|
||
- Queues valid commands in order with bounded backpressure and acknowledges
|
||
QoS 1 commands only after main-thread execution.
|
||
- Cleans up `mpv`, its IPC socket, and its telemetry when playback stops or the
|
||
daemon shuts down.
|
||
- Isolates malformed commands, MQTT callback failures, and optional Home
|
||
Assistant discovery failures so they cannot kill command handling.
|
||
- Optionally publishes nine Home Assistant MQTT discovery sensors. Discovery is
|
||
enabled by default.
|
||
- Sends `mpv` audio only to `pipewire/ytplayer_bus`; it never connects directly
|
||
to HDMI or analog.
|
||
|
||
## Runtime flow
|
||
|
||
```text
|
||
Controller
|
||
|
|
||
| MQTT JSON commands
|
||
v
|
||
player_service.py ---- mpv video ----> DISPLAY=:0
|
||
|
|
||
| pipewire/ytplayer_bus
|
||
v
|
||
YTPlayerBus monitor ports
|
||
|
|
||
v
|
||
Audio Router ----> exactly one physical YTPlayer destination, or none
|
||
```
|
||
|
||
The virtual sink prevents one `mpv` stream from being independently connected
|
||
to both physical outputs. Audio Router is the only component allowed to choose
|
||
the physical destination.
|
||
|
||
## MQTT interface
|
||
|
||
The MQTT topic names, payload shapes, QoS levels, retain flags, client ID, and
|
||
last-will behavior are infrastructure contracts. Coordinate any change with
|
||
the controller and other consumers first.
|
||
|
||
### Topics
|
||
|
||
| Direction | Default topic | Content | QoS | Retained |
|
||
|---|---|---|---:|---:|
|
||
| Controller → player | `youtube/player/cmd` | JSON command object | subscribed at 1 | publisher choice; 1 recommended |
|
||
| Player → consumers | `youtube/player/status` | JSON state/status | 1 | no |
|
||
| Player → consumers | `youtube/player/state` | Flat state string | 0 | yes |
|
||
| Player → consumers | `youtube/player/telemetry` | JSON playback telemetry | 0 | no |
|
||
|
||
`cmd` and `status` are configured under `topics` in `config.yaml`. Telemetry can
|
||
also be overridden with `topics.telemetry`; its listed value is the default.
|
||
The flat `state` topic is intentionally fixed for the existing Companion
|
||
integration.
|
||
|
||
> **Do not retain command messages.** A retained command can execute again when
|
||
> the player reconnects and resubscribes. Publish commands with `retain=false`
|
||
> (the normal `mosquitto_pub` default).
|
||
|
||
The MQTT client ID is `<client_id_prefix>-player`, currently
|
||
`ytplayer-player`. It uses a clean session and a 30-second keepalive.
|
||
|
||
### Last will
|
||
|
||
If the process or connection disappears, the broker publishes this exact
|
||
non-retained QoS 1 payload to `youtube/player/status`:
|
||
|
||
```json
|
||
{"state": "offline"}
|
||
```
|
||
|
||
The daemon deliberately does not send an MQTT `DISCONNECT` during normal
|
||
service termination, preserving this last-will behavior.
|
||
|
||
## Commands
|
||
|
||
Every command must be a JSON object with a non-empty, case-sensitive string
|
||
`action`.
|
||
|
||
### Play a numbered slot
|
||
|
||
```json
|
||
{
|
||
"action": "play",
|
||
"slot": 3,
|
||
"label": "Pre-service loop"
|
||
}
|
||
```
|
||
|
||
This resolves to:
|
||
|
||
```text
|
||
/mnt/Media/YTPlayer/slot3/playlist.m3u
|
||
```
|
||
|
||
The base directory can be overridden for the process with
|
||
`YTPLAYER_MEDIA_BASE`.
|
||
|
||
### Play an explicit playlist
|
||
|
||
```json
|
||
{
|
||
"action": "play",
|
||
"playlist": "/mnt/Media/YTPlayer/special/playlist.m3u",
|
||
"slot": 9,
|
||
"title": "Special event"
|
||
}
|
||
```
|
||
|
||
### Play a direct file
|
||
|
||
```json
|
||
{
|
||
"action": "play",
|
||
"path": "/mnt/Media/YTPlayer/videos/welcome.mp4",
|
||
"label": "Welcome"
|
||
}
|
||
```
|
||
|
||
Path selection priority is `path`, then `playlist`, then the path derived from
|
||
`slot`. The selected path is converted to an absolute path and must exist.
|
||
Files ending in lowercase `.m3u` are passed to `mpv` as playlists; other paths
|
||
are played directly. Relative paths resolve against the service working
|
||
directory (`/home/media/Scripts/Video-Player`), so absolute paths are strongly
|
||
recommended. Media must be readable by user `media`.
|
||
|
||
`label` and `title` are aliases for the optional status label. If both are
|
||
present, `label` wins. An optional slot accompanying an explicit path is
|
||
included in status and telemetry if it can be converted to an integer. This
|
||
command label is separate from telemetry's `title`, which comes from MPV's
|
||
media metadata.
|
||
|
||
If another owned `mpv` is still running, a new play command does not replace
|
||
it. The player reports `busy`, waits five seconds, and then reasserts `playing`
|
||
if the original process is still active. Send `stop` before switching media.
|
||
|
||
### Playback controls
|
||
|
||
```json
|
||
{"action": "stop"}
|
||
```
|
||
|
||
```json
|
||
{"action": "pause"}
|
||
```
|
||
|
||
```json
|
||
{"action": "resume"}
|
||
```
|
||
|
||
```json
|
||
{"action": "toggle"}
|
||
```
|
||
|
||
- `stop` terminates and reaps `mpv`, clears telemetry, and publishes `idle`.
|
||
- `pause` and `resume` set the `mpv` pause property.
|
||
- `toggle` cycles the pause property and publishes the flat state `toggled`;
|
||
use telemetry's `paused` field for the resulting pause value.
|
||
- Pause/resume/toggle status confirms that the request was issued. The current
|
||
compatibility behavior does not change that status if the IPC write fails.
|
||
- Pause/resume/toggle with nothing playing publish an `error` status.
|
||
- Unknown actions are logged and otherwise ignored.
|
||
|
||
There are currently no MQTT actions for seeking, volume, next/previous,
|
||
fullscreen, loop, shuffle, or network-URL playback.
|
||
|
||
### Command-line examples
|
||
|
||
```bash
|
||
mosquitto_pub -h 10.40.0.2 -t youtube/player/cmd -q 1 \
|
||
-m '{"action":"play","slot":1,"label":"Opening"}'
|
||
```
|
||
|
||
```bash
|
||
mosquitto_pub -h 10.40.0.2 -t youtube/player/cmd -q 1 \
|
||
-m '{"action":"stop"}'
|
||
```
|
||
|
||
```bash
|
||
mosquitto_sub -h 10.40.0.2 \
|
||
-t youtube/player/status \
|
||
-t youtube/player/state \
|
||
-t youtube/player/telemetry -v
|
||
```
|
||
|
||
## Status messages
|
||
|
||
Each status update produces two publications:
|
||
|
||
1. A JSON object on `youtube/player/status`.
|
||
2. The same state's plain string on retained `youtube/player/state`.
|
||
|
||
Example:
|
||
|
||
```json
|
||
{
|
||
"state": "playing",
|
||
"path": "/mnt/Media/YTPlayer/slot3/playlist.m3u",
|
||
"slot": 3,
|
||
"label": "Pre-service loop"
|
||
}
|
||
```
|
||
|
||
Possible state strings include:
|
||
|
||
| State | Meaning |
|
||
|---|---|
|
||
| `idle` | Online with no owned playback, or playback has stopped/finished. |
|
||
| `playing` | `mpv` was started or playback was resumed. |
|
||
| `paused` | A pause command was sent. |
|
||
| `toggled` | A pause-toggle command was sent. |
|
||
| `busy` | A play command arrived while another `mpv` was active. |
|
||
| `error` | Invalid input, missing media, process failure, or invalid control state. |
|
||
| `offline` | Broker-generated last will on the JSON status topic only. |
|
||
|
||
Depending on the event, the JSON payload may also contain `path`, `slot`,
|
||
`label`, `message`, or `reason`. Common terminal reasons are `stop`, `finished`,
|
||
`mpv_error`, `sigterm`, and `sigint`.
|
||
|
||
Normal stop publishes cleared telemetry and then `idle` with `reason:"stop"`.
|
||
Natural completion uses `reason:"finished"`. A nonzero MPV exit first publishes
|
||
`error`, then cleared telemetry, then `idle` with `reason:"mpv_error"`. Terminal
|
||
idle normally retains the previous slot but omits the previous path. Repeated
|
||
stop commands each produce their own cleared telemetry and idle publications.
|
||
|
||
On every successful MQTT connection, including a reconnect, the player
|
||
subscribes again and publishes an `idle` status with `"message":"player
|
||
online"`. This is retained historical controller behavior even if playback is
|
||
already active; in that case the current path and slot are included.
|
||
|
||
## Telemetry
|
||
|
||
While `mpv` is active, the daemon observes its IPC properties and publishes at
|
||
the configured interval, defaulting to 0.5 seconds.
|
||
|
||
```json
|
||
{
|
||
"state": "telemetry",
|
||
"slot": 3,
|
||
"path": "/mnt/Media/YTPlayer/slot3/playlist.m3u",
|
||
"position": 12.4,
|
||
"duration": 90.0,
|
||
"remaining": 77.6,
|
||
"position_fmt": "0:12",
|
||
"duration_fmt": "1:30",
|
||
"remaining_fmt": "1:17",
|
||
"paused": false,
|
||
"title": "Welcome",
|
||
"filename": "welcome.mp4",
|
||
"playlist_pos": 0,
|
||
"playlist_count": 4,
|
||
"ts": 1785679200.0
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `state` | Always `telemetry` on this topic. |
|
||
| `slot` | Integer slot supplied by the command, or `null`. |
|
||
| `path` | Active file/playlist path. |
|
||
| `position` | Current position in seconds. |
|
||
| `duration` | Current item duration in seconds. |
|
||
| `remaining` | Non-negative `duration - position`. |
|
||
| `*_fmt` | `M:SS` or `H:MM:SS` formatted values. |
|
||
| `paused` | Current pause property. |
|
||
| `title` | `mpv` media title. |
|
||
| `filename` | Current playlist entry filename. |
|
||
| `playlist_pos` | Zero-based playlist position reported by `mpv`. |
|
||
| `playlist_count` | Number of playlist entries. |
|
||
| `ts` | Unix publication timestamp. |
|
||
|
||
When playback ends or is stopped, one cleared telemetry message is published
|
||
with a null path/title/filename, zero time values, `paused:false`, and null
|
||
playlist positions.
|
||
|
||
## Audio routing
|
||
|
||
`mpv` is always launched with the equivalent of:
|
||
|
||
```text
|
||
--audio-device=pipewire/ytplayer_bus
|
||
--audio-client-name=YTPlayer
|
||
--audio-channels=stereo
|
||
--audio-fallback-to-null=no
|
||
```
|
||
|
||
The no-fallback setting is intentional: if the virtual bus is missing, the
|
||
player must not silently select a physical default sink.
|
||
|
||
The persistent sink definition is maintained in the sibling Audio Router
|
||
repository:
|
||
|
||
```text
|
||
/home/media/Scripts/Audio-Router/pipewire/20-ytplayer-bus.conf
|
||
```
|
||
|
||
It is installed at:
|
||
|
||
```text
|
||
/home/media/.config/pipewire/pipewire-pulse.conf.d/20-ytplayer-bus.conf
|
||
```
|
||
|
||
### Current Audio Router vocabulary
|
||
|
||
- `mixer`: Scarlett capture carrying sanctuary audio for the live audience.
|
||
- `bgmusic`: SnapBGM background music.
|
||
- `hdmi`: BMD HDMI into the ATEM, then the MacBook, then back into the mixer.
|
||
- `analog`: This computer's 3.5 mm output directly into the mixer.
|
||
- `ytplayer`: `YTPlayerBus:monitor_FL/FR`.
|
||
|
||
### Current preset behavior
|
||
|
||
| Preset | Existing program routes | YTPlayer route |
|
||
|---|---|---|
|
||
| `mute_all` | none | disconnected |
|
||
| `service` | mixer → HDMI | disconnected |
|
||
| `announcements` | bgmusic → HDMI and analog | analog only |
|
||
| `aux_only` | bgmusic → analog | analog only |
|
||
| `video` | bgmusic → HDMI | HDMI only |
|
||
|
||
Audio Router rejects any preset that tries to send YTPlayer to HDMI and analog
|
||
simultaneously. It also verifies the old exclusive link is gone before making
|
||
the new one; a disconnect failure therefore leaves YTPlayer on at most the old
|
||
destination instead of bridging both.
|
||
|
||
The player does not select an Audio Router preset. A controller must switch the
|
||
router separately by publishing a plain mode name, for example:
|
||
|
||
```bash
|
||
mosquitto_pub -h 10.40.0.2 -t audio/player/mode/set -m video
|
||
```
|
||
|
||
The controller should select the desired audio preset before starting the
|
||
corresponding video.
|
||
|
||
## Configuration
|
||
|
||
`config.yaml` is loaded relative to `player_service.py`, not the shell's current
|
||
directory.
|
||
|
||
```yaml
|
||
mqtt:
|
||
host: 10.40.0.2
|
||
port: 1883
|
||
username: null
|
||
password: null
|
||
client_id_prefix: ytplayer
|
||
|
||
topics:
|
||
cmd: youtube/player/cmd
|
||
status: youtube/player/status
|
||
# telemetry: youtube/player/telemetry # optional override
|
||
|
||
homeassistant:
|
||
enabled: true
|
||
discovery_prefix: homeassistant
|
||
device_id: ytplayer
|
||
device_name: YT Player
|
||
|
||
player:
|
||
display: :0
|
||
xdg_runtime_dir: /run/user/1000
|
||
mpv_binary: /usr/bin/mpv
|
||
audio_device: pipewire/ytplayer_bus
|
||
audio_client_name: YTPlayer
|
||
audio_channels: stereo
|
||
# ipc_socket: /tmp/mpvsocket # optional override
|
||
# telemetry_interval: 0.5 # minimum 0.1 seconds
|
||
# commands_per_tick: 32 # minimum 1
|
||
# command_queue_size: 512 # minimum 1
|
||
```
|
||
|
||
Restart the daemon after changing configuration.
|
||
|
||
## Home Assistant discovery
|
||
|
||
When enabled, `homeassistant_discovery.py` reuses the player's existing MQTT
|
||
client and publishes nine retained QoS 1 sensor configurations:
|
||
|
||
- `position`
|
||
- `duration`
|
||
- `remaining`
|
||
- `position_fmt`
|
||
- `duration_fmt`
|
||
- `remaining_fmt`
|
||
- `title`
|
||
- `paused`
|
||
- `slot`
|
||
|
||
Their default topic pattern is:
|
||
|
||
```text
|
||
homeassistant/sensor/ytplayer/<entity>/config
|
||
```
|
||
|
||
To disable discovery without affecting any player MQTT topic or command:
|
||
|
||
```yaml
|
||
homeassistant:
|
||
enabled: false
|
||
```
|
||
|
||
Discovery is deliberately isolated: an import or publish failure is logged but
|
||
does not stop the player from subscribing to commands. Disabling discovery
|
||
stops future discovery publications; it does not erase the nine configurations
|
||
already retained by the broker.
|
||
|
||
## Service operation
|
||
|
||
The installed system unit is:
|
||
|
||
```text
|
||
/etc/systemd/system/ytplayer-player.service
|
||
```
|
||
|
||
It runs `player_service.py` as user/group `media`, with `DISPLAY=:0`,
|
||
`XDG_RUNTIME_DIR=/run/user/1000`, and `/home/media/.Xauthority`. It is configured
|
||
with `Restart=always`.
|
||
|
||
```bash
|
||
sudo systemctl start ytplayer-player.service
|
||
sudo systemctl stop ytplayer-player.service
|
||
sudo systemctl restart ytplayer-player.service
|
||
systemctl status ytplayer-player.service --no-pager -l
|
||
```
|
||
|
||
If repeated historical failures tripped systemd's start limit:
|
||
|
||
```bash
|
||
sudo systemctl reset-failed ytplayer-player.service
|
||
sudo systemctl start ytplayer-player.service
|
||
```
|
||
|
||
Follow logs with:
|
||
|
||
```bash
|
||
journalctl -u ytplayer-player.service -f
|
||
```
|
||
|
||
## Dependencies and tests
|
||
|
||
Python dependencies are intentionally limited to:
|
||
|
||
- `paho-mqtt==2.1.0`
|
||
- `PyYAML==6.0`
|
||
|
||
Runtime also requires `mpv`, PipeWire/PipeWire Pulse, the `ytplayer_bus` null
|
||
sink, an X display, and the sibling Audio Router for physical audio routing.
|
||
|
||
Run the regression suite with the same interpreter used by the service:
|
||
|
||
```bash
|
||
cd /home/media/Scripts/Video-Player
|
||
/usr/bin/python3 -m unittest discover -s tests -v
|
||
```
|
||
|
||
The suite covers MQTT topic/QoS/retain compatibility, Home Assistant discovery,
|
||
malformed input isolation, ordered QoS acknowledgements, reconnect generations,
|
||
MPV ownership and cleanup, stale playback generations, virtual-sink pinning,
|
||
and historical status sequences.
|
||
|
||
## Troubleshooting
|
||
|
||
### Service exits during boot or after a network outage
|
||
|
||
The historical crashes were synchronous MQTT startup failures: a boot-time
|
||
`OSError: [Errno 101] Network is unreachable` exited the process repeatedly,
|
||
after which systemd latched the unit at its start limit. The current daemon uses
|
||
asynchronous MQTT connection and 1–30 second reconnect backoff, so a temporarily
|
||
unreachable broker should leave the process active and retrying. Use
|
||
`reset-failed` once if systemd is still latched from those older failures.
|
||
|
||
### Video starts but there is no audio
|
||
|
||
Confirm MPV can see the virtual device:
|
||
|
||
```bash
|
||
XDG_RUNTIME_DIR=/run/user/1000 mpv --audio-device=help
|
||
```
|
||
|
||
Confirm the sink and JACK ports exist:
|
||
|
||
```bash
|
||
pactl list short sinks
|
||
pw-jack jack_lsp YTPlayerBus
|
||
```
|
||
|
||
Then check the current Audio Router mode:
|
||
|
||
```bash
|
||
mosquitto_sub -h 10.40.0.2 -t audio/player/mode/state -C 1
|
||
```
|
||
|
||
`service` and `mute_all` intentionally disconnect YTPlayer. `video` sends it to
|
||
HDMI; `announcements` and `aux_only` send it to analog.
|
||
|
||
### Suspected doubled YTPlayer audio
|
||
|
||
Inspect only the YTPlayer links:
|
||
|
||
```bash
|
||
pw-jack jack_lsp -c YTPlayerBus
|
||
```
|
||
|
||
Each `YTPlayerBus:monitor_*` port must have at most one physical destination.
|
||
Do not add direct MPV-to-HDMI or MPV-to-analog links back into this service.
|
||
|
||
### Commands arrive but playback does not change
|
||
|
||
- Verify the payload is a JSON object containing a string `action`.
|
||
- Check that the requested path exists for user `media`.
|
||
- Stop the current item before sending another play command.
|
||
- Watch `youtube/player/status` for `busy` or `error` details.
|
||
- Inspect the journal for IPC, display, or process errors.
|
||
|
||
### Display errors
|
||
|
||
Verify display `:0`, `/run/user/1000`, and `/home/media/.Xauthority` are valid
|
||
for the `media` account and that the graphical session is running.
|
||
|
||
## Repository files
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `player_service.py` | Daemon, MQTT contract, MPV lifecycle, IPC, and telemetry. |
|
||
| `config.yaml` | Broker, topic, discovery, display, and audio-device settings. |
|
||
| `homeassistant_discovery.py` | Optional discovery publications using the existing MQTT client. |
|
||
| `requirements.txt` | Minimal Python dependencies. |
|
||
| `tests/test_player_service.py` | Regression and infrastructure-contract tests. |
|