feat: overhaul video player controller
This commit is contained in:
+201
-9
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user