fix(r5): address the /code-review high round — 10 findings
- HLS sweep only deletes directories matching the session-name shape; the root is admin-configurable and may be a shared path - playlist unwrap picks the first entry that actually downloaded, not entries[0] - concat quoting keeps backslashes literal (ffmpeg treats them so inside '') - ArrowUp/Down defer while the focused element can still scroll that way - the staging fallback skips yt-dlp working files (.part-Frag/.ytdl/.temp/.fNNN) - the boot HLS sweep runs off the event loop - volume persistence is debounced (was a setItem per wheel notch) and a spin no longer loses notches to the iframe's lagging getVolume - _Breaker.run owns the whole open/call/record protocol; four call sites shrink - should_prune extracted and unit-tested, plus RSS error-vs-empty tests - the worker exits non-zero when the schema never lands
This commit is contained in:
+31
-8
@@ -16,6 +16,7 @@ Design:
|
||||
* Crash-safe: on startup, orphaned `running` jobs are requeued and `downloading` assets reset.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import threading
|
||||
@@ -351,6 +352,17 @@ def _staging_dir(asset_id: int) -> Path:
|
||||
|
||||
|
||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||
# stream as a ready asset.
|
||||
_YTDLP_TEMP_RE = re.compile(
|
||||
r"\.part(-Frag\d+)?$" # in-flight download / fragment
|
||||
r"|\.ytdl$" # resume state
|
||||
r"|\.temp\.[^.]+$" # merge/postprocess temp
|
||||
r"|\.f\d+\.[^.]+$", # per-format stream kept before the merge
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _find_thumbnail(staging: Path, media: Path) -> Path | None:
|
||||
@@ -367,7 +379,8 @@ def _produced_file(info: dict, staging: Path) -> Path:
|
||||
extractor may not fill it in at all — and `Path("")` is `Path(".")`, which *passes* `.exists()`,
|
||||
so a missing value used to sail past the fallback and blow up two steps later (`with_name` on an
|
||||
empty name: the BBC-news failure). Anything that isn't a real file counts as absent; then fall
|
||||
back to the largest non-thumbnail file left in staging."""
|
||||
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
||||
working files (see `_YTDLP_TEMP_RE`)."""
|
||||
req = (info.get("requested_downloads") or [{}])[0]
|
||||
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
||||
if fp:
|
||||
@@ -377,7 +390,9 @@ def _produced_file(info: dict, staging: Path) -> Path:
|
||||
cands = [
|
||||
p
|
||||
for p in staging.iterdir()
|
||||
if p.is_file() and p.suffix.lower() not in _THUMB_SUFFIXES and not p.name.endswith(".part")
|
||||
if p.is_file()
|
||||
and p.suffix.lower() not in _THUMB_SUFFIXES
|
||||
and not _YTDLP_TEMP_RE.search(p.name)
|
||||
]
|
||||
if not cands:
|
||||
raise RuntimeError("yt-dlp produced no output file")
|
||||
@@ -388,11 +403,15 @@ def _media_info(info: dict) -> dict:
|
||||
"""Unwrap a playlist/multi_video extraction to the entry that carries the download.
|
||||
|
||||
A single non-YouTube page (a news article's video, a multi-rendition embed) can extract as a
|
||||
wrapper whose own dict has no `requested_downloads` — the media lives on the first entry."""
|
||||
wrapper whose own dict has no `requested_downloads` — the media lives on an entry. Pick the
|
||||
FIRST entry that actually downloaded something: on a page with a skipped teaser in front of the
|
||||
real video, entries[0] carries no file, and taking it blindly would label the produced file
|
||||
with the wrong id/title/duration."""
|
||||
entries = [e for e in (info.get("entries") or []) if e]
|
||||
if entries and not info.get("requested_downloads"):
|
||||
return {**info, **entries[0]}
|
||||
return info
|
||||
if not entries or info.get("requested_downloads"):
|
||||
return info
|
||||
chosen = next((e for e in entries if e.get("requested_downloads")), entries[0])
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
# Errors worth retrying with a different player-client set (YouTube per-client flakiness).
|
||||
@@ -729,9 +748,13 @@ def main() -> None:
|
||||
return
|
||||
|
||||
# The API may still be applying migrations when we boot. If they never arrive, exit instead of
|
||||
# pressing on into a guaranteed crash — the container's restart policy retries the whole wait.
|
||||
# pressing on into a guaranteed crash — with a FAILURE status, so the container comes back under
|
||||
# any restart policy (`on-failure` too, not just the `unless-stopped` our composes ship) and the
|
||||
# exit code doesn't claim success for a worker that never started working.
|
||||
if not _wait_for_schema():
|
||||
return
|
||||
if _stop.is_set():
|
||||
return # asked to shut down while waiting — that IS a clean exit
|
||||
raise SystemExit(1)
|
||||
_recover_orphans()
|
||||
n = max(1, settings.download_worker_concurrency)
|
||||
log.info("Download worker started (concurrency=%d, root=%s).", n, settings.download_root)
|
||||
|
||||
Reference in New Issue
Block a user