16 KiB
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
.m3uplaylist withmpv. - Maps numbered slots to
/mnt/Media/YTPlayer/slotN/playlist.m3u. - Supports play, stop, pause, resume, and pause-toggle commands.
- Allows only one owned
mpvprocess 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
mpvaudio only topipewire/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 normalmosquitto_pubdefault).
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"}
stopterminates and reapsmpv, clears telemetry, and publishesidle.pauseandresumeset thempvpause property.togglecycles the pause property and publishes the flat statetoggled; use telemetry'spausedfield 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
errorstatus. - 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:
- A JSON object on
youtube/player/status. - 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:
positiondurationremainingposition_fmtduration_fmtremaining_fmttitlepausedslot
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.0PyYAML==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/statusforbusyorerrordetails. - 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. |