feat: coordinate authoritative playback and audio routing
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user