# 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 `-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//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. |