Version 2 Player #1

Merged
Zayin_Admin merged 1 commits from master into origin 2026-08-06 21:47:44 +00:00
6 changed files with 1886 additions and 411 deletions
Showing only changes of commit 6d788ecdc8 - Show all commits
+543
View File
@@ -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
View File
@@ -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"
+86
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-56
View File
@@ -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
+534
View File
@@ -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()