import json import queue import tempfile import threading import time import unittest from dataclasses import replace from pathlib import Path from controller.config import ( ControllerConfig, HomeAssistantConfig, RouterConfig, StorageConfig, ) from controller.downloads import canonical_playlist_path from controller.models import SlotProtectedError from controller.mqtt_adapter import MqttEvent, PublishReceipt from controller.service import ControllerService, ControllerUnavailableError from controller.store import ControllerStore class MutableClock: def __init__(self, value): self.value = value def __call__(self): return self.value class FakeMqttAdapter: """In-memory transport boundary; it never starts a network client.""" def __init__(self, *, connected=True): self.connected = connected self.started = False self.player_commands = [] self.router_modes = [] self.json_messages = [] self.legacy_messages = [] self.acknowledged = [] def start(self): self.started = True def stop(self): self.started = False def _receipt(self): if self.connected: return PublishReceipt(True, message_id=1) return PublishReceipt(False, reason="MQTT is disconnected") def publish_player_command(self, payload): receipt = self._receipt() if receipt.accepted: self.player_commands.append(dict(payload)) return receipt def publish_router_mode(self, mode): receipt = self._receipt() if receipt.accepted: self.router_modes.append(mode) return receipt def publish_json(self, topic, payload, *, qos=0, retain=False): receipt = self._receipt() if receipt.accepted: self.json_messages.append((topic, dict(payload), qos, retain)) return receipt def publish_legacy(self, topic, payload, *, retain=True): receipt = self._receipt() if receipt.accepted: self.legacy_messages.append((topic, payload, retain)) return receipt def acknowledge(self, event): self.acknowledged.append(event) return True class FakeDownloadManager: def __init__(self): self.recovered = False self.recovery_calls = 0 self.expiry_calls = 0 self.stopped = False def recover_pending_deletions(self): self.recovered = True def schedule_pending_deletion_recovery(self): self.recovered = True self.recovery_calls += 1 return True def expire_due_slots(self): self.expiry_calls += 1 return [] def shutdown(self, *, wait): self.stopped = wait class ControllerServiceTests(unittest.TestCase): def setUp(self): self.tempdir = tempfile.TemporaryDirectory() base = Path(self.tempdir.name) self.wall = MutableClock(1_700_000_000.0) self.monotonic = MutableClock(100.0) self.config = ControllerConfig( storage=StorageConfig( media_base=base / "media", data_base=base / "data", retention_seconds=3600, expiry_interval_seconds=3600, uncertain_review_after_seconds=30, ), router=RouterConfig( required_mode="video", confirmation_timeout_seconds=3, when_state_unknown="continue", continue_on_confirmation_failure=True, fallback_restore_mode=None, ), homeassistant=HomeAssistantConfig(enabled=False), ) self.events = queue.Queue() self.store = None self.mqtt = None self.downloads = None self.service = None def tearDown(self): if self.service is not None and self.service.started: self.service.stop() self.tempdir.cleanup() def start_service(self, *, config=None, mqtt=None): config = config or self.config self.events = queue.Queue() self.store = ControllerStore(config.storage, now=self.wall) self.mqtt = mqtt or FakeMqttAdapter() self.downloads = FakeDownloadManager() self.service = ControllerService( config, store=self.store, mqtt_adapter=self.mqtt, event_queue=self.events, download_manager=self.downloads, monotonic=self.monotonic, wall_clock=self.wall, ) self.service.start() self.assertFalse(self.downloads.recovered) return self.service def ready_slot(self, slot=1): reservation = self.store.reserve_download( f"https://example.test/{slot}", "tester", "service test", job_id=f"ready-{slot}", ) self.assertEqual(reservation.slot, slot) self.assertTrue( self.store.mark_download_running( reservation.job_id, reservation.slot, reservation.generation, ) ) self.assertTrue( self.store.complete_download( reservation.job_id, reservation.slot, reservation.generation, title=f"Slot {slot}", items=(("One", "one.mp4"),), ) ) playlist = canonical_playlist_path(self.service.config.storage.media_base, slot) playlist.parent.mkdir(parents=True, exist_ok=True) playlist.write_text("one.mp4\n", encoding="utf-8") return playlist def send_message( self, topic, payload, *, qos=0, retain=False, message_id=None, duplicate=False, controller_echo=False, ): if not isinstance(payload, bytes): payload = ( payload.encode("utf-8") if isinstance(payload, str) else json.dumps(payload).encode("utf-8") ) self.events.put( MqttEvent( kind="message", topic=topic, payload=payload, qos=qos, retain=retain, duplicate=duplicate, message_id=message_id, connection_generation=1, controller_echo=controller_echo, ) ) def wait_for(self, predicate, message): deadline = time.monotonic() + 2.0 while time.monotonic() < deadline: if predicate(): return time.sleep(0.01) self.fail(message) def result_events(self): return [ payload for topic, payload, qos, retain in self.mqtt.json_messages if topic == self.service.config.topics.controller_result ] def test_play_waits_for_router_confirmation_and_player_evidence(self): config = replace( self.config, router=replace(self.config.router, when_state_unknown="attempt"), ) self.start_service(config=config) playlist = self.ready_slot(1) self.send_message(config.topics.router_mode_state, "service", qos=1) self.wait_for( lambda: self.store.router_observation().mode == "service", "Router state was not consumed", ) result = self.service.submit_control({"action": "play", "slot": 1}) self.assertEqual(result["status"], "pending") self.assertEqual(result["router"], "waiting") self.assertEqual(self.mqtt.router_modes, ["video"]) self.assertEqual(self.mqtt.player_commands, []) self.assertEqual(self.service.playback.projected_slot_state(1), "ready") self.send_message( config.topics.router_mode_set, "video", qos=1, controller_echo=True, ) self.wait_for( lambda: len(self.mqtt.acknowledged) == 2, "Controller Router command echo was not consumed", ) self.assertEqual(self.store.live_audio_lease().state, "acquiring") self.assertEqual(self.mqtt.player_commands, []) self.send_message(config.topics.router_mode_state, "video", qos=1) self.wait_for( lambda: len(self.mqtt.player_commands) == 1, "Player command was not released after Router confirmation", ) self.assertEqual( self.mqtt.player_commands, [{"action": "play", "slot": 1, "label": "Slot 1"}], ) self.assertEqual(self.service.playback.projected_slot_state(1), "ready") self.send_message( config.topics.player_status, {"state": "playing", "slot": 1, "path": str(playlist)}, qos=1, ) self.wait_for( lambda: self.store.player_observation().phase == "playing", "authoritative Player status was not reduced", ) self.assertEqual(self.service.playback.projected_slot_state(1), "playing") self.assertIn( "player_operation_confirmed", {event["type"] for event in self.result_events()}, ) def test_manual_router_set_during_acquisition_resolves_by_failure_policy(self): config = replace( self.config, router=replace(self.config.router, when_state_unknown="attempt"), ) self.start_service(config=config) self.ready_slot(1) self.send_message(config.topics.router_mode_state, "service", qos=1) self.wait_for( lambda: self.store.router_observation().mode == "service", "Router state was not consumed", ) result = self.service.submit_control({"action": "play", "slot": 1}) self.assertEqual(result["router"], "waiting") self.send_message(config.topics.router_mode_set, "announcements", qos=1) self.wait_for( lambda: len(self.mqtt.player_commands) == 1, "manual Router intent did not resolve acquisition", ) lease = self.store.get_audio_lease(self.service._session_lease_id) self.assertIsNotNone(lease) self.assertEqual(lease.state, "unconfirmed") warnings = [ event for event in self.result_events() if event["type"] == "audio_router_warning" ] self.assertTrue(warnings) self.assertIs(warnings[-1]["continued"], True) def test_pause_state_remains_telemetry_authoritative(self): self.start_service() playlist = self.ready_slot(1) self.service.submit_control({"action": "play", "slot": 1}) self.wait_for( lambda: self.mqtt.player_commands == [ {"action": "play", "slot": 1, "label": "Slot 1"} ], "play command was not published", ) self.send_message( self.config.topics.player_status, {"state": "playing", "slot": 1, "path": str(playlist)}, qos=1, ) self.wait_for( lambda: self.service.playback.pending is None, "play operation was not confirmed", ) playing_generation = self.store.player_observation().generation self.service.submit_control({"action": "pause"}) self.send_message( self.config.topics.player_status, {"state": "paused", "slot": 1, "path": str(playlist)}, qos=1, ) self.wait_for( lambda: self.store.player_observation().generation > playing_generation, "pause status was not consumed", ) self.assertIsNotNone(self.service.playback.pending) self.assertEqual(self.service.playback.projected_slot_state(1), "playing") self.send_message( self.config.topics.player_telemetry, { "state": "telemetry", "slot": 1, "path": str(playlist), "paused": True, }, ) self.wait_for( lambda: self.service.playback.pending is None, "pause was not confirmed by telemetry", ) self.assertEqual(self.store.player_observation().paused, True) self.assertEqual(self.service.playback.projected_slot_state(1), "paused") self.assertEqual(self.mqtt.player_commands[-1], {"action": "pause"}) def test_retained_controller_command_is_rejected_then_acked_and_reported(self): self.start_service() self.send_message( self.config.topics.controller_command, {"action": "play", "path": "/must/not/run"}, qos=1, retain=True, message_id=42, ) self.wait_for( lambda: len(self.mqtt.acknowledged) == 1, "retained command was not acknowledged after rejection", ) self.assertEqual(self.mqtt.player_commands, []) event = self.result_events()[-1] self.assertEqual(event["version"], 2) self.assertEqual(event["type"], "retained_command_rejected") topic, _, qos, retain = self.mqtt.json_messages[-1] self.assertEqual(topic, "youtube/controller/v2/events") self.assertEqual((qos, retain), (1, False)) self.assertEqual(self.mqtt.acknowledged[0].message_id, 42) def test_optional_result_topic_can_be_disabled_without_affecting_ack(self): config = replace( self.config, topics=replace(self.config.topics, controller_result=None), ) self.start_service(config=config) self.send_message( config.topics.controller_command, {"action": "delete_slot", "slot": 1}, qos=1, retain=True, message_id=7, ) self.wait_for( lambda: len(self.mqtt.acknowledged) == 1, "command was not acknowledged with result topic disabled", ) self.assertEqual(self.mqtt.json_messages, []) def test_broker_unavailable_does_not_prevent_service_startup(self): mqtt = FakeMqttAdapter(connected=False) self.start_service(mqtt=mqtt) health = self.service.health() self.assertTrue(self.service.started) self.assertTrue(mqtt.started) self.assertTrue(health["coordinator_alive"]) self.assertEqual(health["status"], "degraded") self.assertIn("mqtt_disconnected", health["degraded_reasons"]) self.assertIn("awaiting_player_evidence", health["degraded_reasons"]) def test_cleanup_and_downloads_wait_for_post_start_player_evidence(self): self.start_service() self.service._next_expiry = self.monotonic() time.sleep(0.2) self.assertEqual(self.downloads.expiry_calls, 0) self.assertFalse(self.downloads.recovered) with self.assertRaisesRegex( ControllerUnavailableError, "deferred until fresh Player evidence", ): self.service.enqueue_download( "https://example.test/deferred", None, None, ) self.send_message( self.config.topics.player_state, "idle", retain=True, ) self.wait_for( lambda: self.downloads.recovered, "retained Player idle did not release deferred recovery", ) self.wait_for( lambda: self.downloads.expiry_calls > 0, "Player idle did not release deferred expiry", ) self.assertNotIn( "awaiting_player_evidence", self.service.health()["degraded_reasons"], ) def test_manual_delete_requires_explicit_override_while_player_is_unknown(self): self.start_service() self.ready_slot(1) with self.assertRaisesRegex( SlotProtectedError, "confirm_player_unknown=true", ): self.service.submit_control({"action": "delete_slot", "slot": 1}) def test_administrator_can_clear_exact_uncertain_playback_review(self): self.start_service() playlist = self.ready_slot(1) self.store.set_player_observation( availability="unknown", phase="unknown", active_slot=1, active_path=str(playlist), paused=None, reconciliation_state="operator_review", reconciliation_due_at=None, reconciliation_id="review-token", ) guard = self.store.protect_slot( 1, "restart_uncertain", guard_id="review-token", ) review = self.service.health()["reconciliation_reviews"] self.assertEqual(review[0]["review_id"], "review-token") result = self.service.submit_control( { "action": "reconcile_playback", "decision": "clear", "confirm": True, "slot": 1, "review_id": review[0]["review_id"], "actor": "operator", "reason": "Player host was replaced", } ) self.assertEqual( result, {"status": "cleared", "slot": 1, "review_id": "review-token"}, ) self.assertEqual(self.store.list_live_guards(), []) observation = self.store.player_observation() self.assertEqual(observation.reconciliation_state, "admin_cleared") self.assertIsNone(observation.active_slot) self.assertEqual(self.service.playback.projected_slot_state(1), "ready") self.assertEqual(self.result_events()[-1]["type"], "playback_reconciled") def test_busy_player_keeps_video_route_until_actual_playback_finishes(self): config = replace( self.config, router=replace(self.config.router, when_state_unknown="attempt"), ) self.start_service(config=config) self.ready_slot(1) second_playlist = self.ready_slot(2) self.send_message(config.topics.router_mode_state, "service", qos=1) self.wait_for( lambda: self.store.router_observation().mode == "service", "Router service mode was not observed", ) self.service.submit_control({"action": "play", "slot": 1}) self.send_message(config.topics.router_mode_state, "video", qos=1) self.wait_for( lambda: len(self.mqtt.player_commands) == 1, "play command was not released", ) self.send_message( config.topics.player_status, { "state": "busy", "slot": 2, "path": str(second_playlist), "message": "mpv already playing; stop first", }, qos=1, ) self.wait_for( lambda: self.service.playback.pending is None, "busy response was not reduced", ) self.assertEqual(self.store.player_observation().active_slot, 2) self.assertEqual(self.mqtt.router_modes, ["video"]) lease = self.store.live_audio_lease() self.assertIsNotNone(lease) self.assertEqual(lease.state, "held") def test_disconnected_synchronous_play_failure_is_not_reported_pending(self): self.start_service(mqtt=FakeMqttAdapter(connected=False)) self.ready_slot(1) result = self.service.submit_control({"action": "play", "slot": 1}) self.assertEqual(result["status"], "unavailable") self.assertIsNone(self.service.playback.pending) self.assertEqual(self.store.list_live_guards(), []) def test_router_timeout_uses_monotonic_clock_and_continues_with_warning(self): config = replace( self.config, router=replace(self.config.router, when_state_unknown="attempt"), ) self.start_service(config=config) self.ready_slot(1) result = self.service.submit_control({"action": "play", "slot": 1}) self.assertEqual(result["router"], "waiting") self.wall.value -= 10_000 self.monotonic.value += 4 self.wait_for( lambda: len(self.mqtt.player_commands) == 1, "Router timeout did not release play within its finite deadline", ) warnings = [ event for event in self.result_events() if event["type"] == "audio_router_warning" ] self.assertTrue(warnings) self.assertIs(warnings[-1]["continued"], True) def test_duplicate_toggle_is_suppressed_and_translated_idempotently(self): self.start_service() playlist = self.ready_slot(1) self.send_message( self.config.topics.player_telemetry, { "state": "telemetry", "slot": 1, "path": str(playlist), "paused": False, }, ) self.wait_for( lambda: self.store.player_observation().active_slot == 1, "active telemetry was not consumed", ) command = {"action": "toggle"} self.send_message( self.config.topics.controller_command, command, qos=1, message_id=71, ) self.send_message( self.config.topics.controller_command, command, qos=1, message_id=71, duplicate=True, ) self.wait_for( lambda: sum( event.message_id == 71 for event in self.mqtt.acknowledged ) == 2, "duplicate command packets were not both acknowledged", ) self.assertEqual(self.mqtt.player_commands, [{"action": "pause"}]) def test_path_only_managed_play_adds_stable_slot_identity(self): self.start_service() playlist = self.ready_slot(1) result = self.service.submit_control( {"action": "play", "playlist": str(playlist)} ) self.assertEqual(result["status"], "pending") self.assertEqual( self.mqtt.player_commands, [{"action": "play", "playlist": str(playlist), "slot": 1}], ) self.assertEqual(self.service.playback.projected_slot_state(1), "ready") def test_explicit_slot_cannot_be_paired_with_another_managed_slot_path(self): self.start_service() self.ready_slot(1) second_playlist = self.ready_slot(2) with self.assertRaisesRegex(ValueError, "conflicts"): self.service.submit_control( { "action": "play", "slot": 1, "playlist": str(second_playlist), } ) self.assertEqual(self.mqtt.player_commands, []) self.assertEqual(self.store.list_live_guards(), []) def test_administrator_can_clear_external_playback_review_without_slot(self): config = replace( self.config, topics=replace(self.config.topics, controller_result=None), ) self.start_service(config=config) observation = self.store.mark_external_uncertain( "/srv/operator-playlists/special-event.m3u", review_after=self.wall.value, details={"reason": "restart"}, ) self.assertIsNotNone(observation.reconciliation_id) reviews = self.service.health()["reconciliation_reviews"] self.assertEqual(len(reviews), 1) review_id = reviews[0]["review_id"] result = self.service.submit_control( { "action": "reconcile_playback", "decision": "clear", "confirm": True, "review_id": review_id, "actor": "operator", "reason": "Player appliance was replaced", } ) self.assertEqual( result, {"status": "cleared", "review_id": review_id}, ) cleared = self.store.player_observation() self.assertIsNone(cleared.active_path) self.assertEqual(cleared.reconciliation_state, "admin_cleared") def test_external_play_intent_is_durable_immediately_after_puback(self): self.start_service() playlist = Path(self.tempdir.name) / "operator-playlist.m3u" playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8") result = self.service.submit_control( {"action": "play", "playlist": str(playlist)} ) self.assertEqual(result["status"], "pending") self.assertEqual( self.mqtt.player_commands, [{"action": "play", "playlist": str(playlist)}], ) reviews = self.service.health()["reconciliation_reviews"] self.assertEqual(len(reviews), 1) review_id = reviews[0]["review_id"] self.assertIs(reviews[0]["clearable"], False) self.service.stop() self.service = None with ControllerStore(self.config.storage, now=self.wall).open() as reopened: reopened.mark_restart_uncertain() observation = reopened.player_observation() self.assertEqual(observation.active_path, str(playlist)) self.assertEqual(observation.reconciliation_id, review_id) def test_live_external_play_fence_cannot_be_admin_cleared(self): self.start_service() playlist = Path(self.tempdir.name) / "operator-playlist.m3u" playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8") self.service.submit_control( {"action": "play", "playlist": str(playlist)} ) review = self.service.health()["reconciliation_reviews"][0] self.assertIs(review["clearable"], False) with self.assertRaisesRegex(ValueError, "Player operation is pending"): self.service.submit_control( { "action": "reconcile_playback", "decision": "clear", "confirm": True, "review_id": review["review_id"], "actor": "operator", "reason": "clear requested too early", } ) self.assertIsNotNone(self.service.playback.pending) observation = self.store.player_observation() self.assertEqual(observation.reconciliation_id, review["review_id"]) self.assertEqual(observation.active_path, str(playlist)) def test_definite_external_publish_failure_clears_prepublication_guard(self): self.start_service(mqtt=FakeMqttAdapter(connected=False)) playlist = Path(self.tempdir.name) / "operator-playlist.m3u" playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8") result = self.service.submit_control( {"action": "play", "playlist": str(playlist)} ) self.assertEqual(result["status"], "unavailable") self.assertEqual(self.service.health()["reconciliation_reviews"], []) observation = self.store.player_observation() self.assertIsNone(observation.active_path) self.assertIsNone(observation.reconciliation_id) def test_unexpected_reducer_failure_is_not_acked_or_deduplicated(self): self.start_service() original = self.service.playback.handle_status failure_seen = threading.Event() attempts = 0 def fail_once(payload): nonlocal attempts attempts += 1 if attempts == 1: failure_seen.set() raise RuntimeError("transient reducer failure") return original(payload) self.service.playback.handle_status = fail_once payload = { "state": "playing", "slot": 1, "path": str(self.config.storage.media_base / "slot1" / "playlist.m3u"), } self.send_message( self.config.topics.player_status, payload, qos=1, message_id=88, ) self.assertTrue(failure_seen.wait(timeout=1)) time.sleep(0.05) self.assertEqual(self.mqtt.acknowledged, []) self.send_message( self.config.topics.player_status, payload, qos=1, message_id=88, duplicate=True, ) self.wait_for( lambda: len(self.mqtt.acknowledged) == 1, "retried status was not processed and acknowledged", ) self.assertEqual(attempts, 2) self.assertEqual(self.store.player_observation().phase, "playing") if __name__ == "__main__": unittest.main()