Files
2026-08-06 16:46:04 -05:00

16 KiB
Raw Permalink Blame History

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

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:

{"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

{
  "action": "play",
  "slot": 3,
  "label": "Pre-service loop"
}

This resolves to:

/mnt/Media/YTPlayer/slot3/playlist.m3u

The base directory can be overridden for the process with YTPLAYER_MEDIA_BASE.

Play an explicit playlist

{
  "action": "play",
  "playlist": "/mnt/Media/YTPlayer/special/playlist.m3u",
  "slot": 9,
  "title": "Special event"
}

Play a direct file

{
  "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

{"action": "stop"}
{"action": "pause"}
{"action": "resume"}
{"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

mosquitto_pub -h 10.40.0.2 -t youtube/player/cmd -q 1 \
  -m '{"action":"play","slot":1,"label":"Opening"}'
mosquitto_pub -h 10.40.0.2 -t youtube/player/cmd -q 1 \
  -m '{"action":"stop"}'
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:

{
  "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.

{
  "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:

--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:

/home/media/Scripts/Audio-Router/pipewire/20-ytplayer-bus.conf

It is installed at:

/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:

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.

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:

homeassistant/sensor/ytplayer/<entity>/config

To disable discovery without affecting any player MQTT topic or command:

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:

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

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:

sudo systemctl reset-failed ytplayer-player.service
sudo systemctl start ytplayer-player.service

Follow logs with:

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:

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:

XDG_RUNTIME_DIR=/run/user/1000 mpv --audio-device=help

Confirm the sink and JACK ports exist:

pactl list short sinks
pw-jack jack_lsp YTPlayerBus

Then check the current Audio Router mode:

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:

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.