feat: reduce authoritative player mqtt state
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import json
|
||||
import queue
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from controller.config import ControllerConfig, MqttConfig
|
||||
from controller.mqtt_adapter import MqttAdapter
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.published = []
|
||||
self.subscriptions = []
|
||||
self.acks = []
|
||||
self.suppress_exceptions = False
|
||||
self.connect_error = None
|
||||
|
||||
def username_pw_set(self, username, password):
|
||||
self.calls.append(("username_pw_set", username, password))
|
||||
|
||||
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 reconnect_delay_set(self, **kwargs):
|
||||
self.calls.append(("reconnect_delay_set", kwargs))
|
||||
|
||||
def connect_async(self, *args, **kwargs):
|
||||
self.calls.append(("connect_async", args, kwargs))
|
||||
if self.connect_error is not None:
|
||||
raise self.connect_error
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
def connect(self, *args, **kwargs):
|
||||
self.calls.append(("connect", args, kwargs))
|
||||
raise AssertionError("synchronous connect must never be used")
|
||||
|
||||
def loop_start(self):
|
||||
self.calls.append(("loop_start",))
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
def loop_stop(self):
|
||||
self.calls.append(("loop_stop",))
|
||||
|
||||
def disconnect(self):
|
||||
self.calls.append(("disconnect",))
|
||||
|
||||
def subscribe(self, topic, qos=0):
|
||||
self.subscriptions.append((topic, qos))
|
||||
return mqtt.MQTT_ERR_SUCCESS, len(self.subscriptions)
|
||||
|
||||
def publish(self, topic, payload, qos=0, retain=False):
|
||||
self.published.append((topic, payload, qos, retain))
|
||||
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=len(self.published))
|
||||
|
||||
def ack(self, message_id, qos):
|
||||
self.acks.append((message_id, qos))
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
|
||||
class MqttAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = FakeClient()
|
||||
self.events = queue.Queue()
|
||||
self.config = ControllerConfig(
|
||||
mqtt=MqttConfig(
|
||||
host="broker",
|
||||
port=1884,
|
||||
username="user",
|
||||
password="pass",
|
||||
client_id="controller-test",
|
||||
reconnect_min_seconds=1,
|
||||
reconnect_max_seconds=2,
|
||||
command_queue_size=8,
|
||||
)
|
||||
)
|
||||
self.adapter = MqttAdapter(
|
||||
self.config,
|
||||
client=self.client,
|
||||
events=self.events,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.adapter.stop()
|
||||
|
||||
def test_start_is_asynchronous_and_configures_manual_ack(self):
|
||||
self.adapter.start()
|
||||
names = [call[0] for call in self.client.calls]
|
||||
self.assertIn("manual_ack_set", names)
|
||||
self.assertIn("connect_async", names)
|
||||
self.assertIn("loop_start", names)
|
||||
self.assertNotIn("connect", names)
|
||||
connect = next(call for call in self.client.calls if call[0] == "connect_async")
|
||||
self.assertEqual(connect[1], ("broker", 1884))
|
||||
self.assertEqual(connect[2], {"keepalive": 30})
|
||||
|
||||
def test_initial_setup_oserror_does_not_escape_startup(self):
|
||||
self.client.connect_error = OSError("network unreachable")
|
||||
self.adapter.start()
|
||||
self.assertTrue(self.adapter.started)
|
||||
self.assertIn("network unreachable", self.adapter.last_error)
|
||||
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
|
||||
|
||||
def test_connect_subscribes_to_all_authoritative_inputs(self):
|
||||
self.adapter.start()
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.assertTrue(self.adapter.connected)
|
||||
self.assertEqual(
|
||||
self.client.subscriptions,
|
||||
[
|
||||
("youtube/player/status", 1),
|
||||
("youtube/player/telemetry", 0),
|
||||
("youtube/player/state", 0),
|
||||
("youtube/controller/cmd", 1),
|
||||
("audio/player/mode/state", 1),
|
||||
],
|
||||
)
|
||||
event = self.events.get_nowait()
|
||||
self.assertEqual(event.kind, "connected")
|
||||
self.assertEqual(event.connection_generation, 1)
|
||||
|
||||
def test_player_commands_are_exact_qos_one_and_nonretained(self):
|
||||
disconnected = self.adapter.publish_player_command({"action": "stop"})
|
||||
self.assertFalse(disconnected.accepted)
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
receipt = self.adapter.publish_player_command(
|
||||
{"action": "play", "slot": 3, "label": "Opening"}
|
||||
)
|
||||
self.assertTrue(receipt.accepted)
|
||||
topic, payload, qos, retain = self.client.published[-1]
|
||||
self.assertEqual(topic, "youtube/player/cmd")
|
||||
self.assertEqual(json.loads(payload), {"action": "play", "slot": 3, "label": "Opening"})
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertIs(retain, False)
|
||||
|
||||
def test_router_commands_are_qos_one_nonretained_plain_strings(self):
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
receipt = self.adapter.publish_router_mode("video")
|
||||
self.assertTrue(receipt.accepted)
|
||||
self.assertEqual(
|
||||
self.client.published[-1],
|
||||
("audio/player/mode/set", "video", 1, False),
|
||||
)
|
||||
|
||||
def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self):
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
message = SimpleNamespace(
|
||||
topic="youtube/controller/cmd",
|
||||
payload=b'{"action":"stop"}',
|
||||
qos=1,
|
||||
retain=False,
|
||||
mid=42,
|
||||
)
|
||||
self.adapter._on_message(self.client, None, message)
|
||||
event = self.events.get_nowait()
|
||||
self.assertEqual(self.client.acks, [])
|
||||
self.assertEqual(event.message_id, 42)
|
||||
self.assertTrue(self.adapter.acknowledge(event))
|
||||
self.assertEqual(self.client.acks, [(42, 1)])
|
||||
|
||||
def test_stale_mid_from_previous_connection_is_not_acked(self):
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
message = SimpleNamespace(
|
||||
topic="youtube/controller/cmd",
|
||||
payload=b'{"action":"stop"}',
|
||||
qos=1,
|
||||
retain=False,
|
||||
mid=7,
|
||||
)
|
||||
self.adapter._on_message(self.client, None, message)
|
||||
old_event = self.events.get_nowait()
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
self.assertFalse(self.adapter.acknowledge(old_event))
|
||||
self.assertEqual(self.client.acks, [])
|
||||
|
||||
def test_retained_flag_is_preserved_for_coordinator_rejection(self):
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
message = SimpleNamespace(
|
||||
topic="youtube/controller/cmd",
|
||||
payload=b'{"action":"delete_slot","slot":1}',
|
||||
qos=1,
|
||||
retain=True,
|
||||
mid=8,
|
||||
)
|
||||
self.adapter._on_message(self.client, None, message)
|
||||
self.assertIs(self.events.get_nowait().retain, True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,235 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from controller.config import ControllerConfig, StorageConfig
|
||||
from controller.playback import OperationInProgressError, PlaybackReducer
|
||||
from controller.store import ControllerStore
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class PlaybackReducerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
base = Path(self.tempdir.name)
|
||||
self.wall = Clock(1_700_000_000.0)
|
||||
self.monotonic = Clock(100.0)
|
||||
self.config = ControllerConfig(
|
||||
storage=StorageConfig(
|
||||
media_base=base / "media",
|
||||
data_base=base / "data",
|
||||
retention_seconds=100,
|
||||
uncertain_review_after_seconds=30,
|
||||
)
|
||||
)
|
||||
self.store = ControllerStore(self.config.storage, now=self.wall).open()
|
||||
self.reducer = PlaybackReducer(
|
||||
self.config,
|
||||
self.store,
|
||||
monotonic=self.monotonic,
|
||||
wall_clock=self.wall,
|
||||
operation_timeout_seconds=5,
|
||||
)
|
||||
self._ready(1, "one")
|
||||
self._ready(2, "two")
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def _ready(self, slot, suffix):
|
||||
reservation = self.store.reserve_download(
|
||||
f"https://example.test/{suffix}", None, None, job_id=f"job-{suffix}"
|
||||
)
|
||||
self.assertEqual(reservation.slot, slot)
|
||||
self.store.mark_download_running(
|
||||
reservation.job_id, reservation.slot, reservation.generation
|
||||
)
|
||||
self.store.complete_download(
|
||||
reservation.job_id,
|
||||
reservation.slot,
|
||||
reservation.generation,
|
||||
title=suffix.title(),
|
||||
items=((suffix.title(), f"{suffix}.mp4"),),
|
||||
)
|
||||
|
||||
def test_play_intent_is_not_optimistically_reported_playing(self):
|
||||
operation = self.reducer.begin_operation(
|
||||
"play",
|
||||
slot=1,
|
||||
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
)
|
||||
self.assertEqual(operation.state, "awaiting_player")
|
||||
self.assertEqual(self.store.player_observation().phase, "unknown")
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "ready")
|
||||
with self.assertRaises(OperationInProgressError):
|
||||
self.reducer.begin_operation("stop")
|
||||
|
||||
def test_matching_player_playing_confirms_play_and_protects_slot(self):
|
||||
self.reducer.begin_operation(
|
||||
"play",
|
||||
slot=1,
|
||||
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
)
|
||||
effects = self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
}
|
||||
)
|
||||
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
|
||||
self.assertIsNone(self.reducer.pending)
|
||||
self.assertEqual(self.store.player_observation().phase, "playing")
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||
guards = self.store.list_live_guards()
|
||||
self.assertEqual([(guard.slot, guard.kind) for guard in guards], [(1, "player_active")])
|
||||
|
||||
def test_reconnect_online_idle_with_path_is_not_terminal(self):
|
||||
self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
}
|
||||
)
|
||||
effects = self.reducer.handle_status(
|
||||
{
|
||||
"state": "idle",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
"message": "player online",
|
||||
}
|
||||
)
|
||||
self.assertEqual(effects[0].kind, "player_reconnected_active")
|
||||
self.assertEqual(self.store.player_observation().phase, "playing")
|
||||
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
|
||||
|
||||
def test_reconnect_online_idle_without_path_is_fresh_idle_evidence(self):
|
||||
self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
}
|
||||
)
|
||||
self.reducer.handle_status({"state": "idle", "message": "player online"})
|
||||
self.assertEqual(self.store.player_observation().phase, "idle")
|
||||
self.assertEqual(self.store.list_live_guards(), [])
|
||||
|
||||
def test_offline_without_slot_preserves_active_protection_as_unknown(self):
|
||||
self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
}
|
||||
)
|
||||
effects = self.reducer.handle_status({"state": "offline"})
|
||||
observation = self.store.player_observation()
|
||||
self.assertEqual(effects[0].kind, "player_offline")
|
||||
self.assertEqual(observation.active_slot, 1)
|
||||
self.assertEqual(observation.phase, "unknown")
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||
self.assertEqual(self.store.list_live_guards()[0].kind, "restart_uncertain")
|
||||
|
||||
def test_busy_identifies_existing_playback_not_requested_slot(self):
|
||||
self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
}
|
||||
)
|
||||
self.reducer.begin_operation(
|
||||
"play",
|
||||
slot=2,
|
||||
expected_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||
)
|
||||
effects = self.reducer.handle_status(
|
||||
{
|
||||
"state": "busy",
|
||||
"slot": 1,
|
||||
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
"message": "mpv already playing; stop first",
|
||||
}
|
||||
)
|
||||
self.assertIn("operation_failed", [effect.kind for effect in effects])
|
||||
self.assertEqual(self.store.player_observation().active_slot, 1)
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||
self.assertEqual(self.reducer.projected_slot_state(2), "ready")
|
||||
|
||||
def test_pause_status_does_not_override_actual_telemetry(self):
|
||||
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||
self.reducer.handle_telemetry(
|
||||
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
|
||||
)
|
||||
self.reducer.begin_operation("pause", slot=1, expected_path=path)
|
||||
effects = self.reducer.handle_status(
|
||||
{"state": "paused", "slot": 1, "path": path}
|
||||
)
|
||||
self.assertIn("control_issued", [effect.kind for effect in effects])
|
||||
self.assertIsNotNone(self.reducer.pending)
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
|
||||
effects = self.reducer.handle_telemetry(
|
||||
{"state": "telemetry", "slot": 1, "path": path, "paused": True}
|
||||
)
|
||||
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "paused")
|
||||
|
||||
def test_terminal_idle_and_cleared_telemetry_release_protection(self):
|
||||
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||
self.reducer.handle_telemetry(
|
||||
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
|
||||
)
|
||||
self.reducer.begin_operation("stop", slot=1, expected_path=path)
|
||||
effects = self.reducer.handle_telemetry(
|
||||
{"state": "telemetry", "slot": 1, "path": None, "paused": False}
|
||||
)
|
||||
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
|
||||
self.assertEqual(self.store.player_observation().phase, "idle")
|
||||
self.assertEqual(self.store.list_live_guards(), [])
|
||||
self.reducer.handle_status({"state": "idle", "slot": 1, "reason": "stop"})
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "ready")
|
||||
|
||||
def test_error_does_not_release_owned_playback(self):
|
||||
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||
self.reducer.handle_status(
|
||||
{"state": "error", "slot": 1, "path": path, "message": "cannot stop"}
|
||||
)
|
||||
self.assertEqual(self.store.player_observation().phase, "error")
|
||||
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||
|
||||
def test_play_timeout_becomes_unknown_then_operator_review_without_unprotecting(self):
|
||||
self.reducer.begin_operation(
|
||||
"play",
|
||||
slot=1,
|
||||
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
)
|
||||
self.monotonic.value += 6
|
||||
effect = self.reducer.check_timeout()
|
||||
self.assertEqual(effect.kind, "operation_timed_out")
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
|
||||
self.wall.value += 31
|
||||
review = self.reducer.mark_due_reconciliation()
|
||||
self.assertEqual(review[0].kind, "operator_review")
|
||||
self.assertEqual(self.reducer.projected_slot_state(1), "operator_review")
|
||||
self.assertEqual(self.store.list_live_guards()[0].state, "review_required")
|
||||
|
||||
def test_malformed_and_unknown_statuses_are_isolated(self):
|
||||
for payload in (None, [], {}, {"state": "mystery"}):
|
||||
effects = self.reducer.handle_status(payload)
|
||||
self.assertEqual(effects[0].kind, "warning")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user