feat: overhaul video player controller
This commit is contained in:
@@ -24,6 +24,24 @@ class ImmediateExecutor:
|
||||
return future
|
||||
|
||||
|
||||
class QueuedExecutor:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def submit(self, function, *args, **kwargs):
|
||||
future = Future()
|
||||
self.calls.append((future, function, args, kwargs))
|
||||
return future
|
||||
|
||||
def run_next(self):
|
||||
future, function, args, kwargs = self.calls.pop(0)
|
||||
try:
|
||||
future.set_result(function(*args, **kwargs))
|
||||
except BaseException as exc:
|
||||
future.set_exception(exc)
|
||||
return future
|
||||
|
||||
|
||||
class FakeYtDlp:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -267,6 +285,155 @@ class DownloadManagerTests(unittest.TestCase):
|
||||
self.assertEqual(manager.recover_pending_deletions(), [1])
|
||||
self.assertFalse(slot.exists())
|
||||
|
||||
def test_async_deletion_failure_remains_durable_and_is_retryable(self):
|
||||
reservation = self.store.reserve_download(
|
||||
"https://example.test/retry-delete",
|
||||
None,
|
||||
None,
|
||||
job_id="retry-delete",
|
||||
)
|
||||
self.store.fail_download(
|
||||
"retry-delete",
|
||||
1,
|
||||
reservation.generation,
|
||||
"failed",
|
||||
)
|
||||
slot = self.media / "slot1"
|
||||
slot.mkdir(parents=True)
|
||||
(slot / "retry.mp4").write_bytes(b"retry")
|
||||
events = []
|
||||
manager = self.manager(FakeYtDlp(self.media), events)
|
||||
original_recover = manager._recover_deletion
|
||||
attempts = 0
|
||||
|
||||
def fail_once(token):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
raise OSError("transient media failure")
|
||||
return original_recover(token)
|
||||
|
||||
manager._recover_deletion = fail_once
|
||||
token = manager.schedule_delete_slot(1, reason="manual")
|
||||
|
||||
self.assertEqual(token.slot, 1)
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||
self.assertEqual(events[-1].state, "delete_error")
|
||||
self.assertEqual(len(self.store.pending_deletions()), 1)
|
||||
|
||||
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||
self.assertEqual(attempts, 2)
|
||||
self.assertEqual(events[-1].state, "deleted")
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||
self.assertFalse(slot.exists())
|
||||
|
||||
def test_async_pending_recovery_retries_after_guard_is_cleared(self):
|
||||
reservation = self.store.reserve_download(
|
||||
"https://example.test/deferred-async-delete",
|
||||
None,
|
||||
None,
|
||||
job_id="deferred-async-delete",
|
||||
)
|
||||
self.store.fail_download(
|
||||
"deferred-async-delete",
|
||||
1,
|
||||
reservation.generation,
|
||||
"failed",
|
||||
)
|
||||
self.store.begin_delete(1, reason="manual")
|
||||
slot = self.media / "slot1"
|
||||
slot.mkdir(parents=True)
|
||||
(slot / "active.mp4").write_bytes(b"active")
|
||||
self.store.protect_slot(1, "player_active", guard_id="active-player")
|
||||
events = []
|
||||
manager = self.manager(FakeYtDlp(self.media), events)
|
||||
|
||||
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||
self.assertEqual(events[-1].state, "delete_deferred")
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||
self.assertTrue(slot.exists())
|
||||
|
||||
self.store.clear_playback_guards(reason="fresh terminal idle evidence")
|
||||
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||
self.assertEqual(events[-1].state, "deleted")
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||
self.assertFalse(slot.exists())
|
||||
|
||||
def test_guarded_recovery_restores_crash_staged_trash_to_canonical_slot(self):
|
||||
reservation = self.store.reserve_download(
|
||||
"https://example.test/restore-trash",
|
||||
None,
|
||||
None,
|
||||
job_id="restore-trash",
|
||||
)
|
||||
self.store.fail_download(
|
||||
"restore-trash",
|
||||
1,
|
||||
reservation.generation,
|
||||
"failed",
|
||||
)
|
||||
token = self.store.begin_delete(1, reason="expired")
|
||||
trash = (
|
||||
self.media
|
||||
/ ".controller-trash"
|
||||
/ f"slot1.generation{token.generation}.crash"
|
||||
)
|
||||
trash.mkdir(parents=True)
|
||||
(trash / "still-active.mp4").write_bytes(b"active")
|
||||
self.store.protect_slot(1, "player_active", guard_id="fresh-active")
|
||||
events = []
|
||||
manager = self.manager(FakeYtDlp(self.media), events)
|
||||
|
||||
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||
|
||||
canonical = self.media / "slot1"
|
||||
self.assertEqual(events[-1].state, "delete_deferred")
|
||||
self.assertTrue((canonical / "still-active.mp4").exists())
|
||||
self.assertFalse(trash.exists())
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||
|
||||
def test_queued_deletion_rechecks_guard_before_filesystem_mutation(self):
|
||||
reservation = self.store.reserve_download(
|
||||
"https://example.test/queued-delete-guard",
|
||||
None,
|
||||
None,
|
||||
job_id="queued-delete-guard",
|
||||
)
|
||||
self.store.fail_download(
|
||||
"queued-delete-guard",
|
||||
1,
|
||||
reservation.generation,
|
||||
"failed",
|
||||
)
|
||||
self.store.begin_delete(1, reason="manual")
|
||||
slot = self.media / "slot1"
|
||||
slot.mkdir(parents=True)
|
||||
(slot / "must-survive.mp4").write_bytes(b"active")
|
||||
events = []
|
||||
executor = QueuedExecutor()
|
||||
manager = DownloadManager(
|
||||
self.config,
|
||||
self.store,
|
||||
runner=FakeYtDlp(self.media),
|
||||
executor=executor,
|
||||
on_event=events.append,
|
||||
)
|
||||
|
||||
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||
self.store.protect_slot(1, "player_active", guard_id="late-player")
|
||||
executor.run_next()
|
||||
|
||||
self.assertEqual(events[-1].state, "delete_deferred")
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||
self.assertTrue((slot / "must-survive.mp4").exists())
|
||||
|
||||
self.store.clear_playback_guards(reason="fresh terminal idle evidence")
|
||||
self.assertTrue(manager.schedule_pending_deletion_recovery())
|
||||
executor.run_next()
|
||||
self.assertEqual(events[-1].state, "deleted")
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||
self.assertFalse(slot.exists())
|
||||
|
||||
def test_shutdown_terminates_tracked_process_without_waiting_for_job_timeout(self):
|
||||
manager = DownloadManager(self.config, self.store)
|
||||
errors = []
|
||||
|
||||
Reference in New Issue
Block a user