fix: allow genuinely new playlist ingest without fresh Player evidence

The evidence gate on enqueue_download blocked every download, including a
URL that could only land in a slot store.reserve_download already proves is
empty and unguarded. After a Controller-only restart with the Player still
connected and idle, no fresh detailed status ever arrives, so this could
deadlock all new ingest indefinitely even though nothing unsafe would occur.

Narrow the gate to only require fresh evidence when the URL already occupies
a non-empty slot (refresh/reuse). Deletion, expiry, and cleanup/recovery
gating are unchanged.
This commit is contained in:
Media
2026-08-07 17:01:39 -05:00
parent 5e81403be3
commit 270ede8b77
4 changed files with 176 additions and 14 deletions
+20 -4
View File
@@ -404,13 +404,15 @@ class ControllerService:
def _handle_call(self, action: str, payload: Any) -> Any:
if action == "enqueue_download":
assert self.downloads is not None
if not self._cleanup_allowed():
url = payload["url"]
if not self._cleanup_allowed() and self._ingest_requires_evidence(url):
raise ControllerUnavailableError(
"downloads are deferred until fresh Player evidence or "
"administrative playback reconciliation"
"refreshing or replacing existing playlist media is deferred "
"until fresh Player evidence or administrative playback "
"reconciliation"
)
reservation = self.downloads.enqueue(
payload["url"], payload.get("submitter"), payload.get("note")
url, payload.get("submitter"), payload.get("note")
)
result: dict[str, Any] = {
"status": "queued" if reservation.status == "reserved" else reservation.status,
@@ -1261,6 +1263,20 @@ class ControllerService:
def _cleanup_allowed(self) -> bool:
return self._player_evidence_ready or self._operator_cleanup_override
def _ingest_requires_evidence(self, url: Any) -> bool:
"""True when ingesting ``url`` could reuse/refresh occupied media.
A URL that does not already occupy a slot can only land in a slot
that ``reserve_download`` independently proves is empty and
unguarded, so it cannot mutate, delete, or replace media the Player
may currently be using. Only the reuse/refresh path needs the same
conservative evidence gate as deletion and expiry.
"""
if not isinstance(url, str):
return False
return self.store.find_occupied_slot_for_url(url) is not None
def _schedule_deletion_recovery(self) -> None:
if not self._cleanup_allowed():
return
+25
View File
@@ -962,6 +962,31 @@ class ControllerStore:
(slot,),
).fetchone() is not None
def find_occupied_slot_for_url(self, url: str) -> int | None:
"""Return the slot already holding ``url``, if any.
A non-``None`` result means ingest for this exact URL would reuse or
refresh a slot that already holds media, rather than land in a slot
that is genuinely empty. Callers use this to decide whether an
ingest request needs fresh Player evidence before proceeding.
"""
if not isinstance(url, str):
return None
url = url.strip()
if not url:
return None
with self._lock:
row = self.connection.execute(
"""
SELECT slot_number FROM slots
WHERE url = ? AND media_state <> 'empty'
ORDER BY slot_number LIMIT 1
""",
(url,),
).fetchone()
return None if row is None else int(row["slot_number"])
def mark_download_running(self, job_id: str, slot: int, generation: int) -> bool:
with self._transaction() as connection:
now = self._now()