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
+135 -3
View File
@@ -156,8 +156,6 @@ class PlaybackReducerTests(unittest.TestCase):
effects = self.reducer.handle_status(
{
"state": "busy",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
"message": "mpv already playing; stop first",
}
)
@@ -166,6 +164,25 @@ class PlaybackReducerTests(unittest.TestCase):
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
self.assertEqual(self.reducer.projected_slot_state(2), "ready")
def test_real_pathless_busy_without_prior_identity_becomes_unknown(self):
self.reducer.begin_operation(
"play",
slot=2,
expected_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
)
effects = self.reducer.handle_status(
{
"state": "busy",
"message": "mpv already playing; stop first",
}
)
self.assertIn("operation_failed", [effect.kind for effect in effects])
observation = self.store.player_observation()
self.assertEqual(observation.phase, "unknown")
self.assertIsNone(observation.active_slot)
self.assertIsNone(observation.active_path)
def test_pause_status_does_not_override_actual_telemetry(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_telemetry(
@@ -206,9 +223,91 @@ class PlaybackReducerTests(unittest.TestCase):
{"state": "error", "slot": 1, "path": path, "message": "cannot stop"}
)
self.assertEqual(self.store.player_observation().phase, "error")
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
guard = self.store.list_live_guards()[0]
self.assertEqual(guard.slot, 1)
self.assertEqual(guard.kind, "restart_uncertain")
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
def test_pathless_error_converts_previous_active_slot_to_reviewable_unknown(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
self.reducer.handle_status(
{"state": "error", "message": "nothing playing to pause"}
)
observation = self.store.player_observation()
guard = self.store.list_live_guards()[0]
self.assertEqual(observation.active_slot, 1)
self.assertEqual(observation.reconciliation_id, guard.guard_id)
self.assertEqual(observation.reconciliation_state, "awaiting_evidence")
self.assertEqual(guard.kind, "restart_uncertain")
def test_terminal_startup_error_clears_identity_even_if_clear_telemetry_was_lost(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.begin_operation("play", slot=1, expected_path=path)
effects = self.reducer.handle_status(
{
"state": "error",
"slot": 1,
"path": path,
"reason": "startup_error",
"message": "failed to start mpv",
}
)
self.assertIn("player_idle", [effect.kind for effect in effects])
self.assertIn("operation_failed", [effect.kind for effect in effects])
observation = self.store.player_observation()
self.assertEqual(observation.phase, "idle")
self.assertIsNone(observation.active_slot)
self.assertIsNone(observation.active_path)
self.assertEqual(self.store.list_live_guards(), [])
def test_terminal_worker_error_after_cleared_telemetry_remains_idle(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.begin_operation("play", slot=1, expected_path=path)
self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": None, "paused": False}
)
effects = self.reducer.handle_status(
{
"state": "error",
"slot": 1,
"path": path,
"reason": "worker_start_error",
"message": "failed to start playback worker",
}
)
self.assertIn("player_idle", [effect.kind for effect in effects])
observation = self.store.player_observation()
self.assertEqual(observation.phase, "idle")
self.assertIsNone(observation.active_path)
self.assertEqual(self.store.list_live_guards(), [])
def test_player_state_takes_precedence_over_deferred_storage_deletion(self):
reservation = self.store.reserve_download(
"https://example.test/late-player",
None,
None,
job_id="late-player",
)
self.store.fail_download(
"late-player",
1,
reservation.generation,
"failed",
)
self.store.begin_delete(1, reason="expired")
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_status(
{"state": "playing", "slot": 1, "path": path}
)
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
def test_play_timeout_becomes_unknown_then_operator_review_without_unprotecting(self):
self.reducer.begin_operation(
"play",
@@ -225,6 +324,39 @@ class PlaybackReducerTests(unittest.TestCase):
self.assertEqual(self.reducer.projected_slot_state(1), "operator_review")
self.assertEqual(self.store.list_live_guards()[0].state, "review_required")
def test_pause_timeout_invalidates_stale_playing_projection(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
)
self.reducer.begin_operation("pause", slot=1, expected_path=path)
self.monotonic.value += 6
effect = self.reducer.check_timeout()
self.assertEqual(effect.kind, "operation_timed_out")
observation = self.store.player_observation()
self.assertEqual(observation.phase, "unknown")
self.assertIsNone(observation.paused)
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
self.assertEqual(self.store.list_live_guards()[0].kind, "player_active")
def test_resume_timeout_invalidates_stale_paused_projection(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": path, "paused": True}
)
self.reducer.begin_operation("resume", slot=1, expected_path=path)
self.monotonic.value += 6
effect = self.reducer.check_timeout()
self.assertEqual(effect.kind, "operation_timed_out")
observation = self.store.player_observation()
self.assertEqual(observation.phase, "unknown")
self.assertIsNone(observation.paused)
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
def test_malformed_and_unknown_statuses_are_isolated(self):
for payload in (None, [], {}, {"state": "mystery"}):
effects = self.reducer.handle_status(payload)