Version 2 Player
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
# 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. |
|
||||
Reference in New Issue
Block a user