feat: overhaul video player controller

This commit is contained in:
Media
2026-08-06 20:08:12 -05:00
parent 72cc66df92
commit 5e81403be3
10 changed files with 2019 additions and 122 deletions
+201 -9
View File
@@ -14,7 +14,7 @@ from controller.config import (
StorageConfig,
)
from controller.downloads import canonical_playlist_path
from controller.models import SlotProtectedError
from controller.models import SlotProtectedError, StaleOperationError
from controller.mqtt_adapter import MqttEvent, PublishReceipt
from controller.service import ControllerService, ControllerUnavailableError
from controller.store import ControllerStore
@@ -81,11 +81,13 @@ class FakeMqttAdapter:
class FakeDownloadManager:
def __init__(self):
def __init__(self, store=None):
self.store = store
self.recovered = False
self.recovery_calls = 0
self.expiry_calls = 0
self.stopped = False
self.delete_requests = []
def recover_pending_deletions(self):
self.recovered = True
@@ -95,10 +97,20 @@ class FakeDownloadManager:
self.recovery_calls += 1
return True
def expire_due_slots(self):
def schedule_expired_deletions(self):
self.expiry_calls += 1
return []
def schedule_delete_slot(self, slot, *, reason, expected_url=None):
self.delete_requests.append((slot, reason, expected_url))
if self.store is None:
return None
return self.store.begin_delete(
slot,
reason=reason,
expected_url=expected_url,
)
def shutdown(self, *, wait):
self.stopped = wait
@@ -142,7 +154,7 @@ class ControllerServiceTests(unittest.TestCase):
self.events = queue.Queue()
self.store = ControllerStore(config.storage, now=self.wall)
self.mqtt = mqtt or FakeMqttAdapter()
self.downloads = FakeDownloadManager()
self.downloads = FakeDownloadManager(self.store)
self.service = ControllerService(
config,
store=self.store,
@@ -395,6 +407,61 @@ class ControllerServiceTests(unittest.TestCase):
self.assertEqual((qos, retain), (1, False))
self.assertEqual(self.mqtt.acknowledged[0].message_id, 42)
def test_retained_detailed_player_evidence_cannot_clear_restart_protection(self):
self.start_service()
self.store.protect_slot(
1,
"restart_uncertain",
guard_id="restart-review",
)
self.send_message(
self.config.topics.player_status,
{"state": "idle", "slot": 1, "reason": "stop"},
qos=1,
retain=True,
message_id=41,
)
self.send_message(
self.config.topics.player_telemetry,
{
"state": "telemetry",
"slot": 1,
"path": None,
"paused": False,
},
retain=True,
)
self.wait_for(
lambda: any(event.message_id == 41 for event in self.mqtt.acknowledged),
"retained QoS 1 status was not deliberately acknowledged",
)
self.wait_for(
lambda: len(
[
event
for event in self.result_events()
if event["type"] == "retained_player_evidence_rejected"
]
)
== 2,
"retained telemetry rejection was not reported",
)
guards = self.store.list_live_guards()
self.assertEqual(len(guards), 1)
self.assertEqual(guards[0].guard_id, "restart-review")
self.assertIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
retained_warnings = [
event
for event in self.result_events()
if event["type"] == "retained_player_evidence_rejected"
]
self.assertEqual(len(retained_warnings), 2)
def test_optional_result_topic_can_be_disabled_without_affecting_ack(self):
config = replace(
self.config,
@@ -447,19 +514,106 @@ class ControllerServiceTests(unittest.TestCase):
"idle",
retain=True,
)
time.sleep(0.2)
self.assertFalse(self.downloads.recovered)
self.assertEqual(self.downloads.expiry_calls, 0)
self.assertIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
self.send_message(
self.config.topics.player_status,
{"state": "idle", "message": "player online"},
qos=1,
)
self.wait_for(
lambda: self.downloads.recovered,
"retained Player idle did not release deferred recovery",
"detailed Player idle did not release deferred recovery",
)
self.wait_for(
lambda: self.downloads.expiry_calls > 0,
"Player idle did not release deferred expiry",
"detailed Player idle did not release deferred expiry",
)
self.assertNotIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
def test_administrator_can_explicitly_unlock_cleanup_without_player_evidence(self):
self.start_service()
review = self.service.health()["cleanup_reconciliation"]
self.assertIsNotNone(review)
result = self.service.submit_control(
{
"action": "reconcile_playback",
"scope": "cleanup",
"decision": "clear",
"confirm": True,
"review_id": review["review_id"],
"actor": "operator",
"reason": "Player appliance was independently verified offline",
}
)
self.assertEqual(
result,
{
"status": "cleared",
"review_id": review["review_id"],
"scope": "cleanup",
},
)
self.wait_for(
lambda: self.downloads.recovered,
"administrative cleanup override did not schedule recovery",
)
health = self.service.health()
self.assertIsNone(health["cleanup_reconciliation"])
self.assertNotIn("awaiting_player_evidence", health["degraded_reasons"])
def test_cleanup_reconciliation_token_rotates_and_cannot_bypass_a_guard(self):
self.start_service()
first = self.service.health()["cleanup_reconciliation"]["review_id"]
self.events.put(MqttEvent(kind="connected", connection_generation=1))
self.wait_for(
lambda: (
self.service.health()["cleanup_reconciliation"] is not None
and self.service.health()["cleanup_reconciliation"]["review_id"]
!= first
),
"cleanup reconciliation token did not rotate on MQTT connect",
)
current = self.service.health()["cleanup_reconciliation"]["review_id"]
with self.assertRaisesRegex(StaleOperationError, "review_id is stale"):
self.service.submit_control(
{
"action": "reconcile_playback",
"scope": "cleanup",
"decision": "clear",
"confirm": True,
"review_id": first,
"actor": "operator",
"reason": "stale request",
}
)
self.store.protect_slot(1, "restart_uncertain", guard_id="late-guard")
with self.assertRaisesRegex(SlotProtectedError, "known playback protection"):
self.service.submit_control(
{
"action": "reconcile_playback",
"scope": "cleanup",
"decision": "clear",
"confirm": True,
"review_id": current,
"actor": "operator",
"reason": "must not bypass a guard",
}
)
def test_manual_delete_requires_explicit_override_while_player_is_unknown(self):
self.start_service()
self.ready_slot(1)
@@ -536,8 +690,6 @@ class ControllerServiceTests(unittest.TestCase):
config.topics.player_status,
{
"state": "busy",
"slot": 2,
"path": str(second_playlist),
"message": "mpv already playing; stop first",
},
qos=1,
@@ -546,12 +698,34 @@ class ControllerServiceTests(unittest.TestCase):
lambda: self.service.playback.pending is None,
"busy response was not reduced",
)
self.assertEqual(self.store.player_observation().active_slot, 2)
observation = self.store.player_observation()
self.assertIsNone(observation.active_slot)
self.assertIsNone(observation.active_path)
self.assertEqual(observation.phase, "unknown")
self.assertIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
self.assertEqual(self.mqtt.router_modes, ["video"])
lease = self.store.live_audio_lease()
self.assertIsNotNone(lease)
self.assertEqual(lease.state, "held")
self.send_message(
config.topics.player_status,
{
"state": "playing",
"slot": 2,
"path": str(second_playlist),
},
qos=1,
)
self.wait_for(
lambda: self.store.player_observation().active_slot == 2,
"Player did not reassert its actual playback identity",
)
self.assertEqual(self.store.live_audio_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)
@@ -735,6 +909,24 @@ class ControllerServiceTests(unittest.TestCase):
self.assertEqual(observation.reconciliation_id, review["review_id"])
self.assertEqual(observation.active_path, str(playlist))
def test_router_gated_play_marks_all_reconciliation_reviews_not_clearable(self):
self.start_service()
self.ready_slot(1)
self.store.set_router_observation(mode="service", freshness="current")
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["router"], "waiting")
# Model a durable/reconnect review becoming visible while Router
# acquisition is pending.
self.store.protect_slot(
2,
"restart_uncertain",
guard_id="review-during-router-acquisition",
)
reviews = self.service.health()["reconciliation_reviews"]
self.assertEqual(len(reviews), 1)
self.assertIs(reviews[0]["clearable"], False)
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"