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