feat: overhaul video player controller
This commit is contained in:
+163
-23
@@ -90,6 +90,7 @@ class DownloadManager:
|
||||
self._lock = threading.Lock()
|
||||
self._futures: set[Future[Any]] = set()
|
||||
self._processes: set[subprocess.Popen[str]] = set()
|
||||
self._deletions_in_flight: set[tuple[int, int]] = set()
|
||||
self._stopping = False
|
||||
|
||||
def enqueue(
|
||||
@@ -484,21 +485,43 @@ class DownloadManager:
|
||||
slot_directory = canonical_slot_directory(self.config.storage.media_base, token.slot)
|
||||
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||
trash_path: Path | None = None
|
||||
if self._slot_is_guarded(token.slot):
|
||||
return False
|
||||
if os.path.lexists(slot_directory):
|
||||
if slot_directory.is_symlink():
|
||||
slot_directory.unlink()
|
||||
else:
|
||||
trash_root.mkdir(parents=True, exist_ok=True)
|
||||
self._fsync_directory(self.config.storage.media_base)
|
||||
trash_path = trash_root / (
|
||||
f"slot{token.slot}.generation{token.generation}.{uuid.uuid4().hex}"
|
||||
)
|
||||
os.replace(slot_directory, trash_path)
|
||||
self._fsync_directory(self.config.storage.media_base)
|
||||
self._fsync_directory(trash_root)
|
||||
if self._slot_is_guarded(token.slot):
|
||||
if (
|
||||
trash_path is not None
|
||||
and not os.path.lexists(slot_directory)
|
||||
and os.path.lexists(trash_path)
|
||||
):
|
||||
os.replace(trash_path, slot_directory)
|
||||
self._fsync_directory(self.config.storage.media_base)
|
||||
self._fsync_directory(trash_root)
|
||||
return False
|
||||
if trash_path is not None:
|
||||
shutil.rmtree(trash_path)
|
||||
self._fsync_directory(trash_root)
|
||||
return self.store.finish_delete(token)
|
||||
|
||||
def _slot_is_guarded(self, slot: int) -> bool:
|
||||
return any(
|
||||
guard.slot == slot for guard in self.store.list_live_guards()
|
||||
)
|
||||
|
||||
def expire_due_slots(self) -> list[int]:
|
||||
"""Synchronously expire slots for maintenance callers and tests."""
|
||||
|
||||
deleted: list[int] = []
|
||||
for token in self.store.begin_expired_deletions():
|
||||
try:
|
||||
@@ -508,47 +531,164 @@ class DownloadManager:
|
||||
LOG.exception("could not finish expired deletion for slot %s", token.slot)
|
||||
return deleted
|
||||
|
||||
def schedule_delete_slot(
|
||||
self,
|
||||
slot: int,
|
||||
*,
|
||||
reason: str,
|
||||
expected_url: str | None = None,
|
||||
) -> DeleteToken:
|
||||
"""Atomically fence a manual deletion, then finish it off-thread."""
|
||||
|
||||
token = self.store.begin_delete(
|
||||
slot,
|
||||
reason=reason,
|
||||
expected_url=expected_url,
|
||||
)
|
||||
self._schedule_deletion(token)
|
||||
return token
|
||||
|
||||
def schedule_expired_deletions(self) -> list[DeleteToken]:
|
||||
"""Fence all currently due slots and queue filesystem work."""
|
||||
|
||||
tokens = self.store.begin_expired_deletions()
|
||||
for token in tokens:
|
||||
self._schedule_deletion(token)
|
||||
return tokens
|
||||
|
||||
def recover_pending_deletions(self) -> list[int]:
|
||||
recovered: list[int] = []
|
||||
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||
for token in self.store.pending_deletions():
|
||||
if any(
|
||||
guard.slot == token.slot
|
||||
for guard in self.store.list_live_guards()
|
||||
):
|
||||
LOG.warning(
|
||||
"deferring interrupted deletion for protected slot %s",
|
||||
token.slot,
|
||||
)
|
||||
continue
|
||||
if trash_root.is_dir():
|
||||
prefix = f"slot{token.slot}.generation{token.generation}."
|
||||
for candidate in trash_root.iterdir():
|
||||
if candidate.name.startswith(prefix):
|
||||
if candidate.is_symlink() or candidate.is_file():
|
||||
candidate.unlink()
|
||||
elif candidate.is_dir():
|
||||
shutil.rmtree(candidate)
|
||||
if self.finish_deletion(token):
|
||||
if self._recover_deletion(token):
|
||||
recovered.append(token.slot)
|
||||
return recovered
|
||||
|
||||
def schedule_pending_deletion_recovery(self) -> bool:
|
||||
"""Run recoverable trash cleanup without delaying service startup."""
|
||||
"""Queue every safe pending deletion without blocking the coordinator."""
|
||||
|
||||
with self._lock:
|
||||
if self._stopping:
|
||||
return False
|
||||
scheduled = False
|
||||
for token in self.store.pending_deletions():
|
||||
scheduled = self._schedule_deletion(token) or scheduled
|
||||
return scheduled
|
||||
|
||||
def _schedule_deletion(self, token: DeleteToken) -> bool:
|
||||
key = (token.slot, token.generation)
|
||||
with self._lock:
|
||||
if self._stopping or key in self._deletions_in_flight:
|
||||
return False
|
||||
self._deletions_in_flight.add(key)
|
||||
try:
|
||||
future = self._executor.submit(self.recover_pending_deletions)
|
||||
except BaseException:
|
||||
LOG.exception("could not schedule interrupted deletion recovery")
|
||||
future = self._executor.submit(self._deletion_worker, token)
|
||||
except BaseException as exc:
|
||||
with self._lock:
|
||||
self._deletions_in_flight.discard(key)
|
||||
self._emit(
|
||||
DownloadEvent(
|
||||
slot=token.slot,
|
||||
state="delete_error",
|
||||
message=f"could not start deletion worker: {exc}",
|
||||
generation=token.generation,
|
||||
)
|
||||
)
|
||||
return False
|
||||
with self._lock:
|
||||
self._futures.add(future)
|
||||
future.add_done_callback(self._forget_future)
|
||||
return True
|
||||
|
||||
def _deletion_worker(self, token: DeleteToken) -> None:
|
||||
key = (token.slot, token.generation)
|
||||
try:
|
||||
if self._recover_deletion(token):
|
||||
self._emit(
|
||||
DownloadEvent(
|
||||
slot=token.slot,
|
||||
state="deleted",
|
||||
generation=token.generation,
|
||||
)
|
||||
)
|
||||
elif self.store.get_slot(token.slot).media_state == "deleting":
|
||||
self._emit(
|
||||
DownloadEvent(
|
||||
slot=token.slot,
|
||||
state="delete_deferred",
|
||||
message="slot deletion was deferred by playback protection",
|
||||
generation=token.generation,
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
LOG.exception(
|
||||
"could not finish deletion for slot %s generation %s",
|
||||
token.slot,
|
||||
token.generation,
|
||||
)
|
||||
self._emit(
|
||||
DownloadEvent(
|
||||
slot=token.slot,
|
||||
state="delete_error",
|
||||
message=f"deletion remains pending: {exc}",
|
||||
generation=token.generation,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._deletions_in_flight.discard(key)
|
||||
|
||||
def _recover_deletion(self, token: DeleteToken) -> bool:
|
||||
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||
slot_directory = canonical_slot_directory(
|
||||
self.config.storage.media_base,
|
||||
token.slot,
|
||||
)
|
||||
if self._slot_is_guarded(token.slot):
|
||||
LOG.warning(
|
||||
"deferring interrupted deletion for protected slot %s",
|
||||
token.slot,
|
||||
)
|
||||
self._restore_guarded_trash(token, trash_root, slot_directory)
|
||||
return False
|
||||
if trash_root.is_dir():
|
||||
prefix = f"slot{token.slot}.generation{token.generation}."
|
||||
for candidate in trash_root.iterdir():
|
||||
if not candidate.name.startswith(prefix):
|
||||
continue
|
||||
if self._slot_is_guarded(token.slot):
|
||||
self._restore_guarded_trash(
|
||||
token,
|
||||
trash_root,
|
||||
slot_directory,
|
||||
)
|
||||
return False
|
||||
if candidate.is_symlink() or candidate.is_file():
|
||||
candidate.unlink()
|
||||
elif candidate.is_dir():
|
||||
shutil.rmtree(candidate)
|
||||
self._fsync_directory(trash_root)
|
||||
return self.finish_deletion(token)
|
||||
|
||||
def _restore_guarded_trash(
|
||||
self,
|
||||
token: DeleteToken,
|
||||
trash_root: Path,
|
||||
slot_directory: Path,
|
||||
) -> None:
|
||||
if not trash_root.is_dir() or os.path.lexists(slot_directory):
|
||||
return
|
||||
prefix = f"slot{token.slot}.generation{token.generation}."
|
||||
candidates = sorted(
|
||||
candidate
|
||||
for candidate in trash_root.iterdir()
|
||||
if candidate.name.startswith(prefix)
|
||||
)
|
||||
if not candidates:
|
||||
return
|
||||
os.replace(candidates[0], slot_directory)
|
||||
self._fsync_directory(self.config.storage.media_base)
|
||||
self._fsync_directory(trash_root)
|
||||
|
||||
def shutdown(self, *, wait: bool = True) -> None:
|
||||
with self._lock:
|
||||
self._stopping = True
|
||||
|
||||
Reference in New Issue
Block a user