Version 2 Player #1
@@ -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. |
|
||||||
+13
-9
@@ -9,16 +9,20 @@ topics:
|
|||||||
cmd: "youtube/player/cmd"
|
cmd: "youtube/player/cmd"
|
||||||
status: "youtube/player/status"
|
status: "youtube/player/status"
|
||||||
|
|
||||||
|
# Optional Home Assistant discovery. The player MQTT interface is unchanged;
|
||||||
|
# disabling this only skips the nine retained discovery config publications.
|
||||||
|
homeassistant:
|
||||||
|
enabled: true
|
||||||
|
discovery_prefix: "homeassistant"
|
||||||
|
device_id: "ytplayer"
|
||||||
|
device_name: "YT Player"
|
||||||
|
|
||||||
player:
|
player:
|
||||||
display: ":0"
|
display: ":0"
|
||||||
xdg_runtime_dir: "/run/user/1000"
|
xdg_runtime_dir: "/run/user/1000"
|
||||||
mpv_binary: "/usr/bin/mpv"
|
mpv_binary: "/usr/bin/mpv"
|
||||||
|
# MPV always targets this virtual PipeWire sink. Audio Router exclusively
|
||||||
video_audio:
|
# decides whether its monitor is connected to HDMI, analog, or neither.
|
||||||
hdmi:
|
audio_device: "pipewire/ytplayer_bus"
|
||||||
left: "Built-in Audio Pro 3:playback_AUX0"
|
audio_client_name: "YTPlayer"
|
||||||
right: "Built-in Audio Pro 3:playback_AUX1"
|
audio_channels: "stereo"
|
||||||
|
|
||||||
analog:
|
|
||||||
left: "Built-in Audio Pro:playback_AUX0"
|
|
||||||
right: "Built-in Audio Pro:playback_AUX1"
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Home Assistant MQTT discovery publishing for the video player.
|
||||||
|
|
||||||
|
This module deliberately reuses the player's existing MQTT client. It does
|
||||||
|
not create another connection or change any of the player's MQTT topics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_sensor(
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
discovery_prefix: str,
|
||||||
|
device_id: str,
|
||||||
|
device_name: str,
|
||||||
|
telemetry_topic: str,
|
||||||
|
status_topic: str,
|
||||||
|
entity_id: str,
|
||||||
|
name: str,
|
||||||
|
value_template: str,
|
||||||
|
icon: str = "",
|
||||||
|
unit: str = "",
|
||||||
|
) -> None:
|
||||||
|
topic = f"{discovery_prefix}/sensor/{device_id}/{entity_id}/config"
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"unique_id": f"{device_id}_{entity_id}",
|
||||||
|
"state_topic": telemetry_topic,
|
||||||
|
"value_template": value_template,
|
||||||
|
"availability_topic": status_topic,
|
||||||
|
"availability_template": "{% if value_json.state != 'offline' %}online{% else %}offline{% endif %}",
|
||||||
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
"device": {
|
||||||
|
"identifiers": [device_id],
|
||||||
|
"name": device_name,
|
||||||
|
"manufacturer": "Custom",
|
||||||
|
"model": "mpv mqtt player",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if icon:
|
||||||
|
payload["icon"] = icon
|
||||||
|
if unit:
|
||||||
|
payload["unit_of_measurement"] = unit
|
||||||
|
|
||||||
|
client.publish(topic, json.dumps(payload), qos=1, retain=True)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_discovery(
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
discovery_prefix: str,
|
||||||
|
device_id: str,
|
||||||
|
device_name: str,
|
||||||
|
telemetry_topic: str,
|
||||||
|
status_topic: str,
|
||||||
|
) -> None:
|
||||||
|
"""Publish the same nine retained discovery sensors used historically."""
|
||||||
|
sensors = (
|
||||||
|
("position", "YT Position (s)", "{{ value_json.position }}", "mdi:timeline-clock", "s"),
|
||||||
|
("duration", "YT Duration (s)", "{{ value_json.duration }}", "mdi:timer", "s"),
|
||||||
|
("remaining", "YT Remaining (s)", "{{ value_json.remaining }}", "mdi:timer-sand", "s"),
|
||||||
|
("position_fmt", "YT Position", "{{ value_json.position_fmt }}", "mdi:timeline-clock", ""),
|
||||||
|
("duration_fmt", "YT Duration", "{{ value_json.duration_fmt }}", "mdi:timer", ""),
|
||||||
|
("remaining_fmt", "YT Remaining", "{{ value_json.remaining_fmt }}", "mdi:timer-sand", ""),
|
||||||
|
("title", "YT Title", "{{ value_json.title }}", "mdi:music", ""),
|
||||||
|
("paused", "YT Paused", "{{ value_json.paused }}", "mdi:pause-circle", ""),
|
||||||
|
("slot", "YT Slot", "{{ value_json.slot }}", "mdi:playlist-play", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
for entity_id, name, template, icon, unit in sensors:
|
||||||
|
_publish_sensor(
|
||||||
|
client,
|
||||||
|
discovery_prefix=discovery_prefix,
|
||||||
|
device_id=device_id,
|
||||||
|
device_name=device_name,
|
||||||
|
telemetry_topic=telemetry_topic,
|
||||||
|
status_topic=status_topic,
|
||||||
|
entity_id=entity_id,
|
||||||
|
name=name,
|
||||||
|
value_template=template,
|
||||||
|
icon=icon,
|
||||||
|
unit=unit,
|
||||||
|
)
|
||||||
+710
-346
File diff suppressed because it is too large
Load Diff
@@ -1,58 +1,2 @@
|
|||||||
attrs==22.2.0
|
|
||||||
Brotli==1.0.9
|
|
||||||
certifi==2022.9.24
|
|
||||||
cffi==1.15.1
|
|
||||||
chardet==5.1.0
|
|
||||||
charset-normalizer==3.0.1
|
|
||||||
crit==3.17.1
|
|
||||||
cupshelpers==1.0
|
|
||||||
dbus-python==1.3.2
|
|
||||||
distro==1.8.0
|
|
||||||
distro-info==1.5+deb12u1
|
|
||||||
docker==5.0.3
|
|
||||||
docker-compose==1.29.2
|
|
||||||
dockerpty==0.4.1
|
|
||||||
docopt==0.6.2
|
|
||||||
gyp==0.1
|
|
||||||
httplib2==0.20.4
|
|
||||||
idna==3.3
|
|
||||||
JACK-Client==0.5.3
|
|
||||||
jsonpointer==2.3
|
|
||||||
jsonschema==4.10.3
|
|
||||||
Mako==1.2.4.dev0
|
|
||||||
Markdown==3.4.1
|
|
||||||
MarkupSafe==2.1.2
|
|
||||||
mutagen==1.46.0
|
|
||||||
numpy==1.24.2
|
|
||||||
paho-mqtt==2.1.0
|
paho-mqtt==2.1.0
|
||||||
ply==3.11
|
|
||||||
protobuf==4.21.12
|
|
||||||
pycairo==1.20.1
|
|
||||||
pycparser==2.21
|
|
||||||
pycryptodomex==3.11.0
|
|
||||||
pycups==2.0.1
|
|
||||||
pycurl==7.45.2
|
|
||||||
Pygments==2.14.0
|
|
||||||
PyGObject==3.42.2
|
|
||||||
pyparsing==3.0.9
|
|
||||||
pyrsistent==0.18.1
|
|
||||||
PySimpleSOAP==1.16.2
|
|
||||||
pysmbc==1.0.23
|
|
||||||
python-apt==2.6.0
|
|
||||||
python-debian==0.1.49
|
|
||||||
python-debianbts==4.0.1
|
|
||||||
python-dotenv==0.21.0
|
|
||||||
pyxattr==0.8.1
|
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
reportbug==12.0.0
|
|
||||||
requests==2.28.1
|
|
||||||
rfc3987==1.3.8
|
|
||||||
six==1.16.0
|
|
||||||
texttable==1.6.7
|
|
||||||
unattended-upgrades==0.1
|
|
||||||
uritemplate==4.1.1
|
|
||||||
urllib3==1.26.12
|
|
||||||
webcolors==1.11.1
|
|
||||||
websocket-client==1.2.3
|
|
||||||
websockets==10.4
|
|
||||||
yt-dlp==2025.9.26
|
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import homeassistant_discovery
|
||||||
|
import player_service as player
|
||||||
|
|
||||||
|
|
||||||
|
class FakePublishClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.published = []
|
||||||
|
self.subscriptions = []
|
||||||
|
self.acks = []
|
||||||
|
|
||||||
|
def publish(self, topic, payload, qos=0, retain=False):
|
||||||
|
self.published.append((topic, payload, qos, retain))
|
||||||
|
return SimpleNamespace(rc=0)
|
||||||
|
|
||||||
|
def subscribe(self, topic, qos=0):
|
||||||
|
self.subscriptions.append((topic, qos))
|
||||||
|
return (0, 1)
|
||||||
|
|
||||||
|
def ack(self, message_id, qos):
|
||||||
|
self.acks.append((message_id, qos))
|
||||||
|
return player.mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self, returncode=None):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.terminate_calls = 0
|
||||||
|
self.kill_calls = 0
|
||||||
|
self.wait_calls = []
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.terminate_calls += 1
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.kill_calls += 1
|
||||||
|
self.returncode = -9
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
self.wait_calls.append(timeout)
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def playback(generation=1, returncode=None, path="/media/video.mp4", slot=1):
|
||||||
|
return player.PlaybackContext(
|
||||||
|
generation=generation,
|
||||||
|
process=FakeProcess(returncode),
|
||||||
|
path=path,
|
||||||
|
slot=slot,
|
||||||
|
stop_event=threading.Event(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.original_client = player.client
|
||||||
|
self.original_playback = player.current_playback
|
||||||
|
self.original_queue = player.command_queue
|
||||||
|
self.original_mqtt_generation = player.mqtt_connection_generation
|
||||||
|
player.current_playback = None
|
||||||
|
player.command_queue = queue.Queue()
|
||||||
|
player.mqtt_connection_generation = 0
|
||||||
|
player.shutdown_event.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
player.client = self.original_client
|
||||||
|
player.current_playback = self.original_playback
|
||||||
|
player.command_queue = self.original_queue
|
||||||
|
player.mqtt_connection_generation = self.original_mqtt_generation
|
||||||
|
player.shutdown_event.clear()
|
||||||
|
|
||||||
|
def test_decode_command_rejects_non_objects_and_bad_envelopes(self):
|
||||||
|
bad_payloads = ("", "not json", "null", "[]", '"text"', "1", "{}")
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
for payload in bad_payloads:
|
||||||
|
with self.subTest(payload=payload):
|
||||||
|
self.assertIsNone(player.decode_command(payload))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
player.decode_command('{"action":"stop"}'),
|
||||||
|
{"action": "stop"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_decode_command_contains_parser_resource_errors(self):
|
||||||
|
huge_integer = '{"action":"stop","value":' + ("9" * 5000) + "}"
|
||||||
|
deeply_nested = ("[" * 1200) + ("0") + ("]" * 1200)
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
self.assertIsNone(player.decode_command(huge_integer))
|
||||||
|
self.assertIsNone(player.decode_command(deeply_nested))
|
||||||
|
|
||||||
|
def test_malformed_play_fields_do_not_escape(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
malformed = (
|
||||||
|
{"action": "play", "path": 123},
|
||||||
|
{"action": "play", "path": {"bad": True}},
|
||||||
|
{"action": "play", "slot": float("inf")},
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
for command in malformed:
|
||||||
|
with self.subTest(command=command):
|
||||||
|
player.handle_cmd(command)
|
||||||
|
|
||||||
|
status_payloads = [
|
||||||
|
json.loads(payload)
|
||||||
|
for topic, payload, _, _ in fake_client.published
|
||||||
|
if topic == player.TOPIC_STATUS
|
||||||
|
]
|
||||||
|
self.assertEqual(len(status_payloads), len(malformed))
|
||||||
|
self.assertTrue(all(item["state"] == "error" for item in status_payloads))
|
||||||
|
|
||||||
|
def test_on_message_rejects_bad_payload_then_queues_valid_command(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(
|
||||||
|
topic=player.TOPIC_CMD,
|
||||||
|
payload=b"[]",
|
||||||
|
mid=11,
|
||||||
|
qos=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
player.on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
SimpleNamespace(topic=player.TOPIC_CMD, payload=b'{"action":"stop"}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(fake_client.acks, [(11, 1)])
|
||||||
|
self.assertEqual(player.command_queue.qsize(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
player.command_queue.get_nowait().data,
|
||||||
|
{"action": "stop"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_message_contains_unexpected_callback_exception(self):
|
||||||
|
message = SimpleNamespace(topic=player.TOPIC_CMD, payload=b'{"action":"stop"}')
|
||||||
|
with mock.patch.object(player, "decode_command", side_effect=RuntimeError("boom")):
|
||||||
|
with mock.patch.object(player, "log_exception") as logged:
|
||||||
|
player.on_message(None, None, message)
|
||||||
|
logged.assert_called_once()
|
||||||
|
|
||||||
|
def test_repeated_commands_are_preserved_in_order(self):
|
||||||
|
command = {"action": "stop"}
|
||||||
|
for _ in range(3):
|
||||||
|
self.assertTrue(player.enqueue_command(command))
|
||||||
|
self.assertEqual(
|
||||||
|
[player.command_queue.get_nowait().data for _ in range(3)],
|
||||||
|
[command, command, command],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_qos1_command_is_acked_only_after_main_thread_execution(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
message = SimpleNamespace(
|
||||||
|
topic=player.TOPIC_CMD,
|
||||||
|
payload=b'{"action":"stop"}',
|
||||||
|
mid=42,
|
||||||
|
qos=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
player.on_message(None, None, message)
|
||||||
|
self.assertEqual(fake_client.acks, [])
|
||||||
|
|
||||||
|
with mock.patch.object(player, "handle_cmd") as handled:
|
||||||
|
player.process_pending_commands()
|
||||||
|
|
||||||
|
handled.assert_called_once_with({"action": "stop"})
|
||||||
|
self.assertEqual(fake_client.acks, [(42, 1)])
|
||||||
|
|
||||||
|
def test_queued_command_cannot_ack_a_reused_mid_after_reconnect(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
player.mqtt_connection_generation = 3
|
||||||
|
player.enqueue_command(
|
||||||
|
{"action": "stop"},
|
||||||
|
message_id=42,
|
||||||
|
qos=1,
|
||||||
|
connection_generation=3,
|
||||||
|
)
|
||||||
|
player.mqtt_connection_generation = 4
|
||||||
|
|
||||||
|
with mock.patch.object(player, "handle_cmd") as handled:
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.process_pending_commands()
|
||||||
|
|
||||||
|
handled.assert_called_once_with({"action": "stop"})
|
||||||
|
self.assertEqual(fake_client.acks, [])
|
||||||
|
|
||||||
|
def test_status_topics_qos_retain_and_payload_are_unchanged(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
active = playback(path="/media/example.mp4", slot=4)
|
||||||
|
|
||||||
|
player.publish_status("playing", playback=active, label="Example")
|
||||||
|
|
||||||
|
self.assertEqual(fake_client.published[0][0], "youtube/player/status")
|
||||||
|
self.assertEqual(fake_client.published[0][2:], (1, False))
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(fake_client.published[0][1]),
|
||||||
|
{
|
||||||
|
"state": "playing",
|
||||||
|
"path": "/media/example.mp4",
|
||||||
|
"slot": 4,
|
||||||
|
"label": "Example",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
fake_client.published[1],
|
||||||
|
("youtube/player/state", "playing", 0, True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_generation_cannot_stop_current_playback(self):
|
||||||
|
old = playback(generation=1)
|
||||||
|
current = playback(generation=2)
|
||||||
|
player.current_playback = current
|
||||||
|
|
||||||
|
with mock.patch.object(player, "_remove_mpv_socket"):
|
||||||
|
self.assertFalse(player.stop_mpv(expected=old))
|
||||||
|
|
||||||
|
self.assertIs(player.current_playback, current)
|
||||||
|
self.assertEqual(current.process.terminate_calls, 0)
|
||||||
|
self.assertFalse(current.stop_event.is_set())
|
||||||
|
|
||||||
|
def test_stale_generation_cannot_publish_telemetry(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
old = playback(generation=1)
|
||||||
|
player.current_playback = playback(generation=2)
|
||||||
|
|
||||||
|
player.publish_telemetry(
|
||||||
|
old,
|
||||||
|
pos=1,
|
||||||
|
dur=2,
|
||||||
|
paused=False,
|
||||||
|
title=None,
|
||||||
|
filename=None,
|
||||||
|
playlist_pos=0,
|
||||||
|
playlist_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(fake_client.published, [])
|
||||||
|
|
||||||
|
def test_stop_failure_preserves_error_clear_idle_publish_order(self):
|
||||||
|
class FailingTerminateProcess(FakeProcess):
|
||||||
|
def terminate(self):
|
||||||
|
raise OSError("cannot terminate")
|
||||||
|
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
active = playback()
|
||||||
|
active.process = FailingTerminateProcess(returncode=None)
|
||||||
|
player.current_playback = active
|
||||||
|
|
||||||
|
with mock.patch.object(player, "_remove_mpv_socket"):
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.stop_mpv("stop")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[topic for topic, _, _, _ in fake_client.published],
|
||||||
|
[
|
||||||
|
player.TOPIC_STATUS,
|
||||||
|
player.TOPIC_STATE,
|
||||||
|
player.TELEMETRY_TOPIC,
|
||||||
|
player.TOPIC_STATUS,
|
||||||
|
player.TOPIC_STATE,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(json.loads(fake_client.published[0][1])["state"], "error")
|
||||||
|
self.assertIsNone(json.loads(fake_client.published[2][1])["slot"])
|
||||||
|
self.assertEqual(json.loads(fake_client.published[3][1])["state"], "idle")
|
||||||
|
|
||||||
|
def test_unstoppable_process_remains_owned_and_blocks_duplicates(self):
|
||||||
|
class UnstoppableProcess(FakeProcess):
|
||||||
|
def terminate(self):
|
||||||
|
raise OSError("cannot terminate")
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
raise OSError("cannot kill")
|
||||||
|
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
active = playback()
|
||||||
|
active.process = UnstoppableProcess(returncode=None)
|
||||||
|
player.current_playback = active
|
||||||
|
|
||||||
|
with mock.patch.object(player, "_remove_mpv_socket") as remove_socket:
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
self.assertFalse(player.stop_mpv("stop"))
|
||||||
|
|
||||||
|
self.assertIs(player.current_playback, active)
|
||||||
|
remove_socket.assert_not_called()
|
||||||
|
self.assertEqual(
|
||||||
|
[json.loads(payload)["state"] for topic, payload, _, _ in fake_client.published if topic == player.TOPIC_STATUS],
|
||||||
|
["error"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mpv_is_pinned_to_virtual_pipewire_bus(self):
|
||||||
|
fake_process = FakeProcess(returncode=None)
|
||||||
|
fake_thread = SimpleNamespace(
|
||||||
|
name="fake-listener",
|
||||||
|
start=mock.Mock(),
|
||||||
|
join=mock.Mock(),
|
||||||
|
is_alive=mock.Mock(return_value=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(player.os.path, "exists", return_value=True):
|
||||||
|
with mock.patch.object(player.subprocess, "Popen", return_value=fake_process) as popen:
|
||||||
|
with mock.patch.object(player.threading, "Thread", return_value=fake_thread):
|
||||||
|
with mock.patch.object(player, "_remove_mpv_socket"):
|
||||||
|
player.handle_play({"action": "play", "path": "/media/video.mp4"})
|
||||||
|
|
||||||
|
args = popen.call_args.args[0]
|
||||||
|
self.assertIn("--audio-device=pipewire/ytplayer_bus", args)
|
||||||
|
self.assertIn("--audio-client-name=YTPlayer", args)
|
||||||
|
self.assertIn("--audio-channels=stereo", args)
|
||||||
|
self.assertIn("--audio-fallback-to-null=no", args)
|
||||||
|
|
||||||
|
def test_worker_construction_failure_reaps_started_mpv(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
fake_process = FakeProcess(returncode=None)
|
||||||
|
|
||||||
|
with mock.patch.object(player.os.path, "exists", return_value=True):
|
||||||
|
with mock.patch.object(player.subprocess, "Popen", return_value=fake_process):
|
||||||
|
with mock.patch.object(
|
||||||
|
player.threading,
|
||||||
|
"Thread",
|
||||||
|
side_effect=RuntimeError("cannot construct worker"),
|
||||||
|
):
|
||||||
|
with mock.patch.object(player, "_remove_mpv_socket"):
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.handle_play(
|
||||||
|
{"action": "play", "path": "/media/video.mp4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(fake_process.terminate_calls, 1)
|
||||||
|
self.assertIsNone(player.current_playback)
|
||||||
|
status_payloads = [
|
||||||
|
json.loads(payload)
|
||||||
|
for topic, payload, _, _ in fake_client.published
|
||||||
|
if topic == player.TOPIC_STATUS
|
||||||
|
]
|
||||||
|
self.assertEqual(status_payloads[-1]["state"], "error")
|
||||||
|
|
||||||
|
def test_mpv_error_preserves_historical_error_then_idle_sequence(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
failed = playback(returncode=4)
|
||||||
|
player.current_playback = failed
|
||||||
|
|
||||||
|
with mock.patch.object(player, "_remove_mpv_socket"):
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.poll_current_playback()
|
||||||
|
|
||||||
|
state_messages = [
|
||||||
|
(topic, json.loads(payload) if topic == player.TOPIC_STATUS else payload)
|
||||||
|
for topic, payload, _, _ in fake_client.published
|
||||||
|
if topic in (player.TOPIC_STATUS, player.TOPIC_STATE)
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
item[1]["state"] if item[0] == player.TOPIC_STATUS else item[1]
|
||||||
|
for item in state_messages
|
||||||
|
],
|
||||||
|
["error", "error", "idle", "idle"],
|
||||||
|
)
|
||||||
|
self.assertEqual(state_messages[-2][1]["reason"], "mpv_error")
|
||||||
|
|
||||||
|
def test_shutdown_stops_command_drain_without_losing_pending_items(self):
|
||||||
|
player.command_queue.put_nowait({"action": "first"})
|
||||||
|
player.command_queue.put_nowait({"action": "second"})
|
||||||
|
|
||||||
|
def handle_one(command):
|
||||||
|
player.shutdown_event.set()
|
||||||
|
|
||||||
|
with mock.patch.object(player, "handle_cmd", side_effect=handle_one) as handled:
|
||||||
|
player.process_pending_commands()
|
||||||
|
|
||||||
|
handled.assert_called_once_with({"action": "first"})
|
||||||
|
self.assertEqual(player.command_queue.get_nowait(), {"action": "second"})
|
||||||
|
|
||||||
|
def test_main_uses_nonblocking_mqtt_connect(self):
|
||||||
|
class FakeMqttClient(FakePublishClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.calls = []
|
||||||
|
self.suppress_exceptions = False
|
||||||
|
|
||||||
|
def username_pw_set(self, *args):
|
||||||
|
self.calls.append(("username_pw_set", args))
|
||||||
|
|
||||||
|
def reconnect_delay_set(self, **kwargs):
|
||||||
|
self.calls.append(("reconnect_delay_set", kwargs))
|
||||||
|
|
||||||
|
def manual_ack_set(self, enabled):
|
||||||
|
self.calls.append(("manual_ack_set", enabled))
|
||||||
|
|
||||||
|
def max_queued_messages_set(self, count):
|
||||||
|
self.calls.append(("max_queued_messages_set", count))
|
||||||
|
|
||||||
|
def will_set(self, *args, **kwargs):
|
||||||
|
self.calls.append(("will_set", args, kwargs))
|
||||||
|
|
||||||
|
def connect_async(self, *args, **kwargs):
|
||||||
|
self.calls.append(("connect_async", args, kwargs))
|
||||||
|
|
||||||
|
def loop_start(self):
|
||||||
|
self.calls.append(("loop_start",))
|
||||||
|
player.shutdown_event.set()
|
||||||
|
return player.mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.calls.append(("disconnect",))
|
||||||
|
|
||||||
|
def loop_stop(self):
|
||||||
|
self.calls.append(("loop_stop",))
|
||||||
|
|
||||||
|
fake_client = FakeMqttClient()
|
||||||
|
player.client = fake_client
|
||||||
|
|
||||||
|
with mock.patch.object(signal, "signal"):
|
||||||
|
with mock.patch.object(player, "stop_mpv"):
|
||||||
|
player.main()
|
||||||
|
|
||||||
|
call_names = [call[0] for call in fake_client.calls]
|
||||||
|
self.assertIn("connect_async", call_names)
|
||||||
|
self.assertIn("manual_ack_set", call_names)
|
||||||
|
self.assertNotIn("connect", call_names)
|
||||||
|
self.assertNotIn("disconnect", call_names)
|
||||||
|
self.assertLess(call_names.index("connect_async"), call_names.index("loop_start"))
|
||||||
|
|
||||||
|
def test_discovery_failure_cannot_escape_on_connect(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
|
||||||
|
with mock.patch.object(player, "publish_discovery", side_effect=TypeError("bad discovery")):
|
||||||
|
with mock.patch.object(player, "log_exception") as logged:
|
||||||
|
player.on_connect(fake_client, None, None, 0)
|
||||||
|
|
||||||
|
self.assertEqual(fake_client.subscriptions, [(player.TOPIC_CMD, 1)])
|
||||||
|
logged.assert_called_once()
|
||||||
|
self.assertEqual(fake_client.published[0][0], player.TOPIC_STATUS)
|
||||||
|
|
||||||
|
def test_reconnect_preserves_historical_idle_payload_while_playing(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
player.current_playback = playback(path="/media/live.mp4", slot=7)
|
||||||
|
|
||||||
|
with mock.patch.object(player, "publish_discovery"):
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.on_connect(fake_client, None, None, 0)
|
||||||
|
|
||||||
|
self.assertEqual(fake_client.subscriptions, [(player.TOPIC_CMD, 1)])
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(fake_client.published[0][1]),
|
||||||
|
{
|
||||||
|
"state": "idle",
|
||||||
|
"path": "/media/live.mp4",
|
||||||
|
"slot": 7,
|
||||||
|
"message": "player online",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(fake_client.published[1], (player.TOPIC_STATE, "idle", 0, True))
|
||||||
|
|
||||||
|
def test_pause_status_contract_is_preserved_when_ipc_fails(self):
|
||||||
|
fake_client = FakePublishClient()
|
||||||
|
player.client = fake_client
|
||||||
|
player.current_playback = playback()
|
||||||
|
|
||||||
|
with mock.patch.object(player, "mpv_ipc", return_value=False):
|
||||||
|
with mock.patch.object(player, "log"):
|
||||||
|
player.handle_cmd({"action": "pause"})
|
||||||
|
|
||||||
|
self.assertEqual(json.loads(fake_client.published[0][1])["state"], "paused")
|
||||||
|
self.assertEqual(fake_client.published[1][1], "paused")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantDiscoveryTests(unittest.TestCase):
|
||||||
|
def test_discovery_contract_is_unchanged(self):
|
||||||
|
client = FakePublishClient()
|
||||||
|
homeassistant_discovery.publish_discovery(
|
||||||
|
client,
|
||||||
|
discovery_prefix="homeassistant",
|
||||||
|
device_id="ytplayer",
|
||||||
|
device_name="YT Player",
|
||||||
|
telemetry_topic="youtube/player/telemetry",
|
||||||
|
status_topic="youtube/player/status",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(client.published), 9)
|
||||||
|
self.assertTrue(
|
||||||
|
all(qos == 1 and retain is True for _, _, qos, retain in client.published)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[topic for topic, _, _, _ in client.published],
|
||||||
|
[
|
||||||
|
f"homeassistant/sensor/ytplayer/{entity}/config"
|
||||||
|
for entity in (
|
||||||
|
"position",
|
||||||
|
"duration",
|
||||||
|
"remaining",
|
||||||
|
"position_fmt",
|
||||||
|
"duration_fmt",
|
||||||
|
"remaining_fmt",
|
||||||
|
"title",
|
||||||
|
"paused",
|
||||||
|
"slot",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
first_payload = json.loads(client.published[0][1])
|
||||||
|
self.assertEqual(first_payload["state_topic"], "youtube/player/telemetry")
|
||||||
|
self.assertEqual(first_payload["availability_topic"], "youtube/player/status")
|
||||||
|
self.assertEqual(first_payload["unit_of_measurement"], "s")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user