feat: coordinate authoritative playback and audio routing

This commit is contained in:
Media
2026-08-06 18:52:01 -05:00
parent 8b3bdae8a0
commit 3ee7f94d72
17 changed files with 4863 additions and 186 deletions
+2
View File
@@ -29,6 +29,7 @@ class ContractCharacterizationTests(unittest.TestCase):
self.assertEqual(topics.playback_base, "youtube/media/playback")
self.assertEqual(topics.router_mode_set, "audio/player/mode/set")
self.assertEqual(topics.router_mode_state, "audio/player/mode/state")
self.assertEqual(topics.controller_result, "youtube/controller/v2/events")
self.assertEqual(PLAYER_COMMAND_QOS, 1)
self.assertIs(PLAYER_COMMAND_RETAIN, False)
self.assertEqual(SLOT_IDS, tuple(range(1, 11)))
@@ -79,6 +80,7 @@ class ConfigurationTests(unittest.TestCase):
self.assertIs(config.router.continue_on_confirmation_failure, True)
self.assertIsNone(config.router.fallback_restore_mode)
self.assertEqual(config.storage.uncertain_review_after_seconds, 900.0)
self.assertEqual(config.mqtt.publish_ack_timeout_seconds, 2.0)
def test_documented_environment_overrides_apply(self):
env = {
+38
View File
@@ -1,6 +1,8 @@
import os
import subprocess
import tempfile
import threading
import time
import unittest
from concurrent.futures import Future
from pathlib import Path
@@ -194,6 +196,42 @@ class DownloadManagerTests(unittest.TestCase):
self.assertFalse(slot.exists())
self.assertEqual(self.store.get_slot(1).media_state, "empty")
def test_shutdown_terminates_tracked_process_without_waiting_for_job_timeout(self):
manager = DownloadManager(self.config, self.store)
errors = []
def run_long_process():
try:
manager._run_command(
["/bin/sh", "-c", "sleep 60"],
check=True,
text=True,
timeout=60,
capture_output=True,
)
except BaseException as exc:
errors.append(exc)
worker = threading.Thread(target=run_long_process)
worker.start()
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
with manager._lock:
if manager._processes:
break
time.sleep(0.01)
else:
self.fail("test process did not start")
started = time.monotonic()
manager.shutdown(wait=False)
worker.join(timeout=3)
self.assertFalse(worker.is_alive())
self.assertLess(time.monotonic() - started, 3)
self.assertTrue(errors)
with manager._lock:
self.assertEqual(manager._processes, set())
if __name__ == "__main__":
unittest.main()
+234 -1
View File
@@ -1,5 +1,6 @@
import json
import queue
import threading
import unittest
from types import SimpleNamespace
@@ -9,6 +10,20 @@ from controller.config import ControllerConfig, MqttConfig
from controller.mqtt_adapter import MqttAdapter
class FakeMessageInfo:
def __init__(self, mid, published):
self.rc = mqtt.MQTT_ERR_SUCCESS
self.mid = mid
self.published = published
self.waited = []
def wait_for_publish(self, timeout=None):
self.waited.append(timeout)
def is_published(self):
return self.published
class FakeClient:
def __init__(self):
self.calls = []
@@ -17,6 +32,9 @@ class FakeClient:
self.acks = []
self.suppress_exceptions = False
self.connect_error = None
self.loop_result = mqtt.MQTT_ERR_SUCCESS
self.publish_acknowledged = None
self.publish_hook = None
def username_pw_set(self, username, password):
self.calls.append(("username_pw_set", username, password))
@@ -42,7 +60,7 @@ class FakeClient:
def loop_start(self):
self.calls.append(("loop_start",))
return mqtt.MQTT_ERR_SUCCESS
return self.loop_result
def loop_stop(self):
self.calls.append(("loop_stop",))
@@ -56,6 +74,12 @@ class FakeClient:
def publish(self, topic, payload, qos=0, retain=False):
self.published.append((topic, payload, qos, retain))
if self.publish_hook is not None:
self.publish_hook(topic, payload, qos, retain)
if self.publish_acknowledged is not None:
return FakeMessageInfo(
len(self.published), self.publish_acknowledged
)
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=len(self.published))
def ack(self, message_id, qos):
@@ -106,6 +130,66 @@ class MqttAdapterTests(unittest.TestCase):
self.assertIn("network unreachable", self.adapter.last_error)
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
def test_loop_start_failure_does_not_escape_startup(self):
self.client.loop_result = mqtt.MQTT_ERR_UNKNOWN
self.adapter.start()
self.assertTrue(self.adapter.started)
self.assertIn("failed to start MQTT network loop", self.adapter.last_error)
def test_stop_during_blocked_connect_never_starts_network_loop_afterward(self):
entered = threading.Event()
release = threading.Event()
original_connect = self.client.connect_async
def blocked_connect(*args, **kwargs):
entered.set()
release.wait(timeout=2)
return original_connect(*args, **kwargs)
self.client.connect_async = blocked_connect
starter = threading.Thread(target=self.adapter.start)
starter.start()
self.assertTrue(entered.wait(timeout=1))
self.adapter.stop()
release.set()
starter.join(timeout=2)
self.assertFalse(starter.is_alive())
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
self.assertFalse(self.adapter.started)
def test_stop_wins_against_connect_callback_blocked_during_subscribe(self):
self.adapter.start()
entered = threading.Event()
release = threading.Event()
original_subscribe = self.client.subscribe
def blocked_subscribe(topic, qos=0):
entered.set()
release.wait(timeout=2)
return original_subscribe(topic, qos=qos)
self.client.subscribe = blocked_subscribe
callback = threading.Thread(
target=self.adapter._on_connect,
args=(self.client, None, None, 0),
)
callback.start()
self.assertTrue(entered.wait(timeout=1))
stopper = threading.Thread(target=self.adapter.stop)
stopper.start()
release.set()
callback.join(timeout=2)
stopper.join(timeout=2)
self.assertFalse(callback.is_alive())
self.assertFalse(stopper.is_alive())
self.assertFalse(self.adapter.started)
self.assertFalse(self.adapter.connected)
self.assertLess(len(self.client.subscriptions), 6)
self.assertTrue(self.events.empty())
self.assertFalse(
self.adapter.publish_player_command({"action": "stop"}).accepted
)
def test_connect_subscribes_to_all_authoritative_inputs(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
@@ -117,6 +201,7 @@ class MqttAdapterTests(unittest.TestCase):
("youtube/player/telemetry", 0),
("youtube/player/state", 0),
("youtube/controller/cmd", 1),
("audio/player/mode/set", 1),
("audio/player/mode/state", 1),
],
)
@@ -127,6 +212,7 @@ class MqttAdapterTests(unittest.TestCase):
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.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
receipt = self.adapter.publish_player_command(
@@ -140,6 +226,7 @@ class MqttAdapterTests(unittest.TestCase):
self.assertIs(retain, False)
def test_router_commands_are_qos_one_nonretained_plain_strings(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
receipt = self.adapter.publish_router_mode("video")
@@ -149,7 +236,75 @@ class MqttAdapterTests(unittest.TestCase):
("audio/player/mode/set", "video", 1, False),
)
def test_router_self_echo_is_marked_only_while_publish_is_in_flight(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
def echo(topic, payload, qos, retain):
self.adapter._on_message(
self.client,
None,
SimpleNamespace(
topic=topic,
payload=payload.encode("utf-8"),
qos=qos,
retain=retain,
dup=False,
mid=31,
),
)
self.client.publish_hook = echo
self.assertTrue(self.adapter.publish_router_mode("video").accepted)
self.assertTrue(self.events.get_nowait().controller_echo)
self.client.publish_hook = None
self.adapter._on_message(
self.client,
None,
SimpleNamespace(
topic="audio/player/mode/set",
payload=b"video",
qos=1,
retain=False,
dup=False,
mid=32,
),
)
self.assertFalse(self.events.get_nowait().controller_echo)
def test_retained_router_set_never_consumes_controller_echo_marker(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
def retained_then_live_echo(topic, payload, qos, retain):
for message_retain, mid in ((True, 41), (False, 42)):
self.adapter._on_message(
self.client,
None,
SimpleNamespace(
topic=topic,
payload=payload.encode("utf-8"),
qos=qos,
retain=message_retain,
dup=False,
mid=mid,
),
)
self.client.publish_hook = retained_then_live_echo
self.assertTrue(self.adapter.publish_router_mode("video").accepted)
retained_event = self.events.get_nowait()
live_event = self.events.get_nowait()
self.assertTrue(retained_event.retain)
self.assertFalse(retained_event.controller_echo)
self.assertFalse(live_event.retain)
self.assertTrue(live_event.controller_echo)
def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
@@ -167,6 +322,7 @@ class MqttAdapterTests(unittest.TestCase):
self.assertEqual(self.client.acks, [(42, 1)])
def test_stale_mid_from_previous_connection_is_not_acked(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
@@ -184,6 +340,7 @@ class MqttAdapterTests(unittest.TestCase):
self.assertEqual(self.client.acks, [])
def test_retained_flag_is_preserved_for_coordinator_rejection(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
@@ -196,6 +353,82 @@ class MqttAdapterTests(unittest.TestCase):
self.adapter._on_message(self.client, None, message)
self.assertIs(self.events.get_nowait().retain, True)
def test_duplicate_flag_is_preserved_for_coordinator_deduplication(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
topic="youtube/controller/cmd",
payload=b'{"action":"toggle"}',
qos=1,
retain=False,
dup=True,
mid=9,
)
self.adapter._on_message(self.client, None, message)
self.assertIs(self.events.get_nowait().duplicate, True)
def test_missing_puback_is_uncertain_and_invalidates_connection(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
self.client.publish_acknowledged = False
receipt = self.adapter.publish_player_command({"action": "stop"})
self.assertFalse(receipt.accepted)
self.assertTrue(receipt.uncertain)
self.assertIn("no broker PUBACK", receipt.reason)
self.assertFalse(self.adapter.connected)
def test_optional_qos_event_puback_failure_does_not_invalidate_transport(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
self.client.publish_acknowledged = False
receipt = self.adapter.publish_json(
"youtube/controller/v2/events",
{"version": 2, "type": "warning"},
qos=1,
retain=False,
)
self.assertFalse(receipt.accepted)
self.assertTrue(receipt.uncertain)
self.assertTrue(self.adapter.connected)
def test_discarded_transport_releases_callback_blocked_on_full_queue(self):
events = queue.Queue(maxsize=1)
client = FakeClient()
adapter = MqttAdapter(self.config, client=client, events=events)
try:
adapter.start()
adapter._on_connect(client, None, None, 0)
self.assertTrue(events.full())
callback = threading.Thread(
target=adapter._on_message,
args=(
client,
None,
SimpleNamespace(
topic="youtube/controller/cmd",
payload=b'{"action":"stop"}',
qos=1,
retain=False,
dup=False,
mid=99,
),
),
)
callback.start()
callback.join(timeout=0.05)
self.assertTrue(callback.is_alive())
adapter._discard_unacknowledged_transport("test transport discard")
callback.join(timeout=1)
self.assertFalse(callback.is_alive())
self.assertEqual(events.qsize(), 1)
self.assertEqual(events.get_nowait().kind, "connected")
finally:
adapter.stop()
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -230,6 +230,154 @@ class PlaybackReducerTests(unittest.TestCase):
effects = self.reducer.handle_status(payload)
self.assertEqual(effects[0].kind, "warning")
def test_contradictory_slot_and_path_cannot_confirm_play(self):
expected = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
self.reducer.begin_operation("play", slot=1, expected_path=expected)
effects = self.reducer.handle_status(
{
"state": "playing",
"slot": 1,
"path": str(
self.config.storage.media_base / "slot2" / "playlist.m3u"
),
}
)
self.assertEqual(effects[0].kind, "warning")
self.assertIsNotNone(self.reducer.pending)
self.assertEqual(self.store.player_observation().phase, "unknown")
def test_mismatched_active_evidence_fails_intent_and_is_not_overwritten(self):
self.reducer.begin_operation(
"play",
slot=1,
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
)
effects = self.reducer.handle_status(
{
"state": "playing",
"slot": 2,
"path": "/mnt/Media/YTPlayer/slot2/playlist.m3u",
}
)
self.assertIn("operation_failed", [effect.kind for effect in effects])
self.assertIsNone(self.reducer.pending)
self.monotonic.value += 100
self.assertIsNone(self.reducer.check_timeout())
observation = self.store.player_observation()
self.assertEqual(observation.active_slot, 2)
self.assertEqual(observation.phase, "playing")
self.assertEqual(self.store.list_live_guards()[0].slot, 2)
def test_bare_idle_does_not_clear_live_playback(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
effects = self.reducer.handle_status({"state": "idle", "slot": 1})
self.assertEqual(effects[0].kind, "warning")
self.assertEqual(self.store.player_observation().phase, "playing")
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
def test_path_only_evidence_derives_and_protects_managed_slot(self):
path = str(self.config.storage.media_base / "slot2" / "playlist.m3u")
self.reducer.handle_telemetry(
{"state": "telemetry", "slot": None, "path": path, "paused": False}
)
observation = self.store.player_observation()
self.assertEqual(observation.active_slot, 2)
self.assertEqual(self.store.list_live_guards()[0].slot, 2)
def test_duplicate_offline_evidence_does_not_postpone_review(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
self.reducer.handle_status({"state": "offline"})
first_deadline = self.store.list_live_guards()[0].review_after
self.wall.value += 20
self.reducer.handle_status({"state": "offline"})
self.assertEqual(
self.store.list_live_guards()[0].review_after,
first_deadline,
)
def test_repeated_active_telemetry_reuses_guard_without_history_churn(self):
path = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
payload = {
"state": "telemetry",
"slot": 1,
"path": path,
"paused": False,
}
self.reducer.handle_telemetry(payload)
first_guard = self.store.list_live_guards()[0]
for _ in range(20):
self.reducer.handle_telemetry(payload)
self.assertEqual(self.store.list_live_guards()[0].guard_id, first_guard.guard_id)
count = self.store.connection.execute(
"SELECT COUNT(*) FROM slot_guards"
).fetchone()[0]
self.assertEqual(count, 1)
def test_external_play_timeout_transitions_to_review_without_assuming_stop(self):
path = "/srv/operator-playlists/special-event.m3u"
self.reducer.begin_operation("play", expected_path=path)
self.monotonic.value += 6
timeout = self.reducer.check_timeout()
self.assertEqual(timeout.kind, "operation_timed_out")
uncertain = self.store.player_observation()
self.assertEqual(uncertain.active_path, path)
self.assertEqual(uncertain.reconciliation_state, "awaiting_evidence")
self.assertIsNotNone(uncertain.reconciliation_id)
self.wall.value += 31
effects = self.reducer.mark_due_reconciliation()
self.assertEqual(len(effects), 1)
self.assertEqual(effects[0].kind, "operator_review")
self.assertEqual(effects[0].operation_id, uncertain.reconciliation_id)
review = self.store.player_observation()
self.assertEqual(review.reconciliation_state, "operator_review")
self.assertEqual(review.active_path, path)
def test_external_pause_does_not_bleed_to_a_different_path(self):
first_path = "/srv/operator-playlists/first.m3u"
second_path = "/srv/operator-playlists/second.m3u"
self.reducer.handle_telemetry(
{
"state": "telemetry",
"path": first_path,
"paused": True,
}
)
self.assertIs(self.store.player_observation().paused, True)
self.reducer.handle_status(
{"state": "playing", "path": second_path}
)
observation = self.store.player_observation()
self.assertEqual(observation.active_path, second_path)
self.assertIsNone(observation.paused)
self.assertEqual(observation.phase, "playing")
def test_stop_timeout_preserves_existing_operator_review_and_token(self):
path = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
self.reducer.handle_status(
{"state": "playing", "slot": 1, "path": path}
)
self.reducer.handle_status({"state": "offline"})
self.wall.value += 31
self.reducer.mark_due_reconciliation()
before = self.store.player_observation()
guard_before = self.store.list_live_guards()[0]
self.assertEqual(before.reconciliation_state, "operator_review")
self.reducer.begin_operation("stop", slot=1, expected_path=path)
self.monotonic.value += 6
effect = self.reducer.check_timeout()
self.assertEqual(effect.kind, "operation_timed_out")
after = self.store.player_observation()
guard_after = self.store.list_live_guards()[0]
self.assertEqual(after.reconciliation_state, "operator_review")
self.assertEqual(after.reconciliation_id, before.reconciliation_id)
self.assertEqual(guard_after.guard_id, guard_before.guard_id)
self.assertEqual(guard_after.state, "review_required")
if __name__ == "__main__":
unittest.main()
+316
View File
@@ -0,0 +1,316 @@
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from controller.config import ControllerConfig, RouterConfig, StorageConfig
from controller.mqtt_adapter import PublishReceipt
from controller.router import AudioRouterCoordinator
from controller.store import ControllerStore
class Clock:
def __init__(self, value=1000.0):
self.value = value
def __call__(self):
return self.value
class ModePublisher:
def __init__(self):
self.modes = []
self.accepted = True
self.reason = None
def __call__(self, mode):
self.modes.append(mode)
return PublishReceipt(self.accepted, message_id=len(self.modes), reason=self.reason)
class AudioRouterCoordinatorTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
base = Path(self.tempdir.name)
self.clock = Clock()
self.base_config = ControllerConfig(
storage=StorageConfig(
media_base=base / "media",
data_base=base / "data",
),
router=RouterConfig(
required_mode="video",
confirmation_timeout_seconds=3,
when_state_unknown="attempt",
continue_on_confirmation_failure=True,
fallback_restore_mode=None,
),
)
self.store = ControllerStore(self.base_config.storage, now=self.clock).open()
self.publisher = ModePublisher()
def tearDown(self):
self.store.close()
self.tempdir.cleanup()
def coordinator(self, router_config=None):
config = self.base_config
if router_config is not None:
config = replace(config, router=router_config)
return AudioRouterCoordinator(
config,
self.store,
self.publisher,
clock=self.clock,
)
def test_known_mode_is_confirmed_before_play_and_restored_afterward(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-one")
self.assertEqual(decision.action, "waiting")
self.assertEqual(self.publisher.modes, ["video"])
self.assertEqual(router.observe_mode("video").action, "ready")
lease = self.store.get_audio_lease(decision.lease_id)
self.assertEqual(lease.state, "held")
self.assertEqual(lease.previous_mode, "service")
restore = router.release(decision.lease_id)
self.assertEqual(restore.action, "restore_waiting")
self.assertEqual(self.publisher.modes, ["video", "service"])
self.assertEqual(router.observe_mode("service").action, "restored")
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "released")
def test_default_unknown_state_timeout_continues_with_warning_not_hang(self):
router = self.coordinator()
decision = router.acquire("play-timeout")
self.assertEqual(decision.action, "waiting")
self.assertEqual(self.publisher.modes, ["video"])
self.clock.value += 2.9
self.assertIsNone(router.check_deadline())
self.clock.value += 0.2
decision = router.check_deadline()
self.assertEqual(decision.action, "ready")
self.assertTrue(decision.continued)
self.assertIn("did not confirm", decision.warning)
lease = self.store.get_audio_lease(decision.lease_id)
self.assertEqual(lease.state, "unconfirmed")
self.assertEqual(router.release(lease.lease_id).action, "released")
self.assertEqual(self.publisher.modes, ["video"])
def test_timeout_can_reject_when_continue_policy_is_disabled(self):
router = self.coordinator(
replace(
self.base_config.router,
continue_on_confirmation_failure=False,
)
)
decision = router.acquire("play-reject")
self.clock.value += 4
decision = router.check_deadline()
self.assertEqual(decision.action, "rejected")
self.assertFalse(decision.continued)
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "abandoned")
def test_unknown_state_continue_policy_does_not_wait_or_gain_late_ownership(self):
router = self.coordinator(
replace(self.base_config.router, when_state_unknown="continue")
)
decision = router.acquire("play-immediate")
self.assertEqual(decision.action, "ready")
self.assertTrue(decision.continued)
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "unconfirmed")
self.assertEqual(router.observe_mode("video").action, "observed")
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "unconfirmed")
def test_unknown_state_reject_policy_does_not_publish(self):
router = self.coordinator(
replace(self.base_config.router, when_state_unknown="reject")
)
decision = router.acquire("play-reject")
self.assertEqual(decision.action, "rejected")
self.assertEqual(self.publisher.modes, [])
def test_already_video_requires_no_change_and_no_restore(self):
router = self.coordinator()
router.observe_mode("video")
decision = router.acquire("play-video")
self.assertEqual(decision.action, "ready")
self.assertEqual(self.publisher.modes, [])
self.assertEqual(router.release(decision.lease_id).action, "released")
self.assertEqual(self.publisher.modes, [])
def test_confirmed_unknown_acquisition_can_use_configured_fallback(self):
router = self.coordinator(
replace(self.base_config.router, fallback_restore_mode="service")
)
decision = router.acquire("play-fallback")
self.assertEqual(router.observe_mode("video").action, "ready")
lease = self.store.get_audio_lease(decision.lease_id)
self.assertEqual(lease.restoration_source, "configured_fallback")
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
self.assertEqual(self.publisher.modes, ["video", "service"])
def test_operator_change_is_sticky_even_if_mode_later_returns_to_video(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-operator")
router.observe_mode("video")
lost = router.observe_mode("announcements")
self.assertEqual(lost.action, "ownership_lost")
router.observe_mode("video")
self.assertEqual(router.release(decision.lease_id).action, "released")
self.assertEqual(self.publisher.modes, ["video"])
self.assertEqual(
self.store.get_audio_lease(decision.lease_id).state,
"ownership_lost",
)
def test_connection_loss_relinquishes_confirmed_lease(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-disconnect")
router.observe_mode("video")
lost = router.connection_lost()
self.assertEqual(lost.action, "ownership_lost")
self.assertEqual(router.release(decision.lease_id).action, "released")
self.assertEqual(self.publisher.modes, ["video"])
def test_publish_failure_uses_confirmation_failure_policy(self):
self.publisher.accepted = False
self.publisher.reason = "MQTT disconnected"
router = self.coordinator()
decision = router.acquire("play-publish-fail")
self.assertEqual(decision.action, "ready")
self.assertTrue(decision.continued)
self.assertIn("MQTT disconnected", decision.warning)
def test_restoration_timeout_is_bounded_and_not_retried(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-restore-timeout")
router.observe_mode("video")
router.release(decision.lease_id)
self.clock.value += 4
timeout = router.check_deadline()
self.assertEqual(timeout.action, "restore_failed")
self.assertEqual(self.publisher.modes, ["video", "service"])
self.assertIsNone(router.check_deadline())
def test_repeated_previous_state_during_acquisition_waits_for_deadline(self):
router = self.coordinator()
router.observe_mode("service")
waiting = router.acquire("play-repeated-state")
self.assertEqual(router.observe_mode("service").action, "observed")
self.assertEqual(
self.store.get_audio_lease(waiting.lease_id).state,
"acquiring",
)
self.clock.value += 4
decision = router.check_deadline()
self.assertEqual(decision.action, "ready")
self.assertTrue(decision.continued)
self.assertEqual(
self.store.get_audio_lease(waiting.lease_id).state,
"unconfirmed",
)
router.release(waiting.lease_id)
self.assertEqual(self.publisher.modes, ["video"])
def test_repeated_target_state_is_observation_not_operator_takeover(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-repeated-state")
self.assertEqual(router.observe_mode("video").action, "ready")
self.assertEqual(router.observe_mode("video").action, "observed")
self.assertEqual(
self.store.get_audio_lease(decision.lease_id).state,
"held",
)
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
self.assertEqual(self.publisher.modes, ["video", "service"])
def test_controller_mode_set_echo_does_not_relinquish_ownership(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-self-echo")
self.assertEqual(
router.observe_mode_command("video", controller_echo=True).action,
"self_echo",
)
self.assertEqual(router.observe_mode("video").action, "ready")
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
self.assertEqual(
router.observe_mode_command("service", controller_echo=True).action,
"self_echo",
)
self.assertEqual(router.observe_mode("service").action, "restored")
def test_manual_mode_set_during_acquisition_uses_continue_policy(self):
router = self.coordinator()
router.observe_mode("service")
waiting = router.acquire("play-manual-acquire")
decision = router.observe_mode_command("announcements")
self.assertEqual(decision.action, "ready")
self.assertTrue(decision.continued)
self.assertEqual(
self.store.get_audio_lease(waiting.lease_id).state,
"unconfirmed",
)
self.assertIsNone(self.store.get_audio_lease(waiting.lease_id).confirmation_deadline)
def test_manual_mode_set_during_acquisition_can_reject(self):
router = self.coordinator(
replace(
self.base_config.router,
continue_on_confirmation_failure=False,
)
)
router.observe_mode("service")
waiting = router.acquire("play-manual-reject")
decision = router.observe_mode_command("announcements")
self.assertEqual(decision.action, "rejected")
self.assertFalse(decision.continued)
self.assertEqual(
self.store.get_audio_lease(waiting.lease_id).state,
"abandoned",
)
def test_manual_mode_set_while_held_relinquishes_restoration(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-manual-held")
router.observe_mode("video")
lost = router.observe_mode_command("video")
self.assertEqual(lost.action, "ownership_lost")
self.assertEqual(router.release(decision.lease_id).action, "released")
self.assertEqual(self.publisher.modes, ["video"])
def test_manual_mode_set_while_restoring_relinquishes_restoration(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-manual-restore")
router.observe_mode("video")
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
lost = router.observe_mode_command("announcements")
self.assertEqual(lost.action, "ownership_lost")
self.assertEqual(
self.store.get_audio_lease(decision.lease_id).state,
"ownership_lost",
)
self.assertEqual(self.publisher.modes, ["video", "service"])
def test_retained_mode_set_does_not_claim_live_operator_intent(self):
router = self.coordinator()
router.observe_mode("service")
decision = router.acquire("play-retained-set")
router.observe_mode("video")
ignored = router.observe_mode_command("announcements", retained=True)
self.assertEqual(ignored.action, "retained_ignored")
self.assertEqual(
self.store.get_audio_lease(decision.lease_id).state,
"held",
)
if __name__ == "__main__":
unittest.main()
+742
View File
@@ -0,0 +1,742 @@
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.mqtt_adapter import MqttEvent, PublishReceipt
from controller.service import ControllerService
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.stopped = False
def recover_pending_deletions(self):
self.recovered = True
def expire_due_slots(self):
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.assertTrue(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"])
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()
+196
View File
@@ -1,6 +1,7 @@
import concurrent.futures
import hashlib
import os
import sqlite3
import tempfile
import unittest
from pathlib import Path
@@ -49,6 +50,37 @@ class FreshStoreTests(StoreTestCase):
self.assertTrue(all(slot.media_state == "empty" for slot in slots))
self.assertEqual(store.player_observation().phase, "unknown")
def test_schema_one_database_upgrades_external_reconciliation_atomically(self):
with self.open_store() as store:
database_path = store.path
connection = sqlite3.connect(database_path)
try:
connection.execute(
"ALTER TABLE player_observation DROP COLUMN reconciliation_id"
)
connection.execute("DELETE FROM schema_migrations WHERE version = 2")
connection.execute("PRAGMA user_version = 1")
connection.commit()
finally:
connection.close()
with self.open_store() as upgraded:
self.assertEqual(
upgraded.connection.execute("PRAGMA user_version").fetchone()[0],
2,
)
columns = {
row["name"]
for row in upgraded.connection.execute(
"PRAGMA table_info(player_observation)"
).fetchall()
}
self.assertIn("reconciliation_id", columns)
migration = upgraded.connection.execute(
"SELECT name FROM schema_migrations WHERE version = 2"
).fetchone()
self.assertEqual(migration["name"], "external_playback_reconciliation")
def test_slot_reservation_is_committed_before_worker_transition(self):
with self.open_store() as store:
reservation = store.reserve_download(
@@ -127,6 +159,25 @@ class FreshStoreTests(StoreTestCase):
self.assertEqual(store.get_slot(2).slot, 2)
self.assertEqual(store.get_slot(2).url, "https://example.test/two")
def test_concurrent_first_open_installs_only_one_valid_database(self):
stores = [ControllerStore(self.config, now=self.clock) for _ in range(2)]
def open_store(store):
store.open()
return [slot.slot for slot in store.list_slots()]
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(open_store, stores))
self.assertEqual(results, [list(range(1, 11)), list(range(1, 11))])
markers = stores[0].connection.execute(
"SELECT name FROM data_migrations ORDER BY name"
).fetchall()
self.assertEqual([row["name"] for row in markers], ["fresh_database_v1"])
finally:
for store in stores:
store.close()
class GuardAndExpiryTests(StoreTestCase):
def _ready_slot(self, store, *, slot_expected=1):
@@ -196,6 +247,7 @@ class GuardAndExpiryTests(StoreTestCase):
paused=False,
reconciliation_state="confirmed",
reconciliation_due_at=None,
reconciliation_id=None,
)
guard = store.mark_restart_uncertain()
self.assertEqual(guard.slot, 1)
@@ -204,6 +256,150 @@ class GuardAndExpiryTests(StoreTestCase):
self.assertEqual(observation.reconciliation_state, "awaiting_evidence")
self.assertEqual(observation.active_slot, 1)
def test_external_playback_restart_gets_stable_review_token_and_exact_clear(self):
external_path = "/srv/operator-playlists/live.m3u"
with self.open_store() as store:
store.set_player_observation(
availability="online",
phase="playing",
active_slot=None,
active_path=external_path,
paused=False,
reconciliation_state="confirmed",
reconciliation_due_at=None,
reconciliation_id=None,
)
self.assertIsNone(store.mark_restart_uncertain())
first = store.player_observation()
self.assertEqual(first.reconciliation_state, "awaiting_evidence")
self.assertIsNotNone(first.reconciliation_id)
self.assertEqual(first.reconciliation_due_at, self.clock.value + 30)
self.clock.value += 10
self.assertIsNone(store.mark_restart_uncertain())
repeated = store.player_observation()
self.assertEqual(repeated.reconciliation_id, first.reconciliation_id)
self.assertEqual(repeated.reconciliation_due_at, first.reconciliation_due_at)
self.clock.value = first.reconciliation_due_at + 1
due = store.mark_external_reconciliation_due()
self.assertEqual(due, (first.reconciliation_id, external_path))
self.assertIsNone(store.mark_external_reconciliation_due())
review = store.player_observation()
self.assertEqual(review.reconciliation_state, "operator_review")
self.assertEqual(review.active_path, external_path)
self.assertFalse(
store.clear_external_reconciliation(
"stale-review",
actor="operator",
reason="Player was replaced",
)
)
self.assertTrue(
store.clear_external_reconciliation(
first.reconciliation_id,
actor="operator",
reason="Player was replaced",
)
)
cleared = store.player_observation()
self.assertEqual(cleared.reconciliation_state, "admin_cleared")
self.assertIsNone(cleared.active_path)
self.assertIsNone(cleared.reconciliation_id)
def test_restart_converts_existing_active_guard_and_preserves_deadline(self):
with self.open_store() as store:
self._ready_slot(store)
store.protect_slot(1, "player_active", guard_id="active-before-restart")
store.set_player_observation(
availability="online",
phase="playing",
active_slot=1,
active_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
paused=False,
reconciliation_state="confirmed",
reconciliation_due_at=None,
reconciliation_id=None,
)
guard = store.mark_restart_uncertain()
self.assertEqual(guard.guard_id, "active-before-restart")
self.assertEqual(guard.kind, "restart_uncertain")
first_deadline = guard.review_after
self.clock.value += 10
repeated = store.mark_restart_uncertain()
self.assertEqual(repeated.guard_id, guard.guard_id)
self.assertEqual(repeated.review_after, first_deadline)
def test_restart_converts_pre_evidence_pending_guard_for_admin_review(self):
with self.open_store() as store:
self._ready_slot(store)
store.protect_slot(1, "pending_play", guard_id="published-before-crash")
guard = store.mark_restart_uncertain()
self.assertEqual(guard.guard_id, "published-before-crash")
self.assertEqual(guard.kind, "restart_uncertain")
self.assertEqual(store.player_observation().active_slot, 1)
self.assertTrue(
store.clear_guard(
1,
guard.guard_id,
actor="operator",
reason="Player replaced",
)
)
def test_guard_review_transition_is_reported_only_once(self):
with self.open_store() as store:
self._ready_slot(store)
store.protect_slot(
1,
"restart_uncertain",
guard_id="one-warning",
review_after=self.clock.value,
)
self.assertEqual(len(store.mark_due_guards_for_review()), 1)
self.assertEqual(store.mark_due_guards_for_review(), [])
def test_clearing_one_legacy_guard_does_not_erase_other_observation(self):
with self.open_store() as store:
self._ready_slot(store)
second = store.reserve_download(
"https://example.test/second", None, None, job_id="second"
)
store.mark_download_running("second", 2, second.generation)
store.complete_download(
"second",
2,
second.generation,
title="Second",
items=(("Second", "second.mp4"),),
)
first_guard = store.protect_slot(
1, "legacy_uncertain", guard_id="legacy-one"
)
store.protect_slot(2, "player_active", guard_id="active-two")
store.set_player_observation(
availability="online",
phase="playing",
active_slot=2,
active_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
paused=False,
reconciliation_state="confirmed",
reconciliation_due_at=None,
reconciliation_id=None,
)
self.assertTrue(
store.clear_guard(
1,
first_guard.guard_id,
actor="operator",
reason="legacy evidence resolved",
)
)
observation = store.player_observation()
self.assertEqual(observation.active_slot, 2)
self.assertEqual(observation.phase, "playing")
class LegacyMigrationTests(StoreTestCase):
def write_legacy(self, data):