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"
|
||||
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:
|
||||
display: ":0"
|
||||
xdg_runtime_dir: "/run/user/1000"
|
||||
mpv_binary: "/usr/bin/mpv"
|
||||
|
||||
video_audio:
|
||||
hdmi:
|
||||
left: "Built-in Audio Pro 3:playback_AUX0"
|
||||
right: "Built-in Audio Pro 3:playback_AUX1"
|
||||
|
||||
analog:
|
||||
left: "Built-in Audio Pro:playback_AUX0"
|
||||
right: "Built-in Audio Pro:playback_AUX1"
|
||||
# MPV always targets this virtual PipeWire sink. Audio Router exclusively
|
||||
# decides whether its monitor is connected to HDMI, analog, or neither.
|
||||
audio_device: "pipewire/ytplayer_bus"
|
||||
audio_client_name: "YTPlayer"
|
||||
audio_channels: "stereo"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+698
-334
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
|
||||
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
|
||||
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