From b200f61642a03e60097953bd76a7570bad593345 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 3 Jul 2026 02:42:36 +0200 Subject: [PATCH] feat(downloads): clear multi-phase download progress + transient-error retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress bar looked broken (95% -> 5% -> recount) because yt-dlp downloads the video and audio streams separately (each 0->100%) then merges — with no indication of which step is running. Now: - worker labels the phase from the stream codecs (video / audio) and via a postprocessor hook (merging / processing), so the user sees what's happening - DownloadCenter shows the phase as the status and renders an indeterminate pulse (no bogus %) during merge/processing; phase i18n en/hu/de - yt-dlp retries (3) + fragment_retries (5) so transient network blips self-heal instead of failing the job (seen once as a DNS 'No address associated with hostname' error) Verified: phase transitions queued -> video -> processing -> done. --- backend/app/downloads/formats.py | 4 +++ backend/app/worker.py | 28 +++++++++++++++++- frontend/src/components/DownloadCenter.tsx | 32 +++++++++++++++------ frontend/src/i18n/locales/de/downloads.json | 6 ++++ frontend/src/i18n/locales/en/downloads.json | 6 ++++ frontend/src/i18n/locales/hu/downloads.json | 6 ++++ 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 6360ca3..cd5d766 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -91,6 +91,10 @@ def build_ydl_opts(spec: dict, outtmpl: str, progress_hook) -> dict: "writethumbnail": True, # kept for the poster.jpg sidecar (and embed, if requested) "progress_hooks": [progress_hook], "postprocessors": [], + # Self-heal transient network blips instead of failing the whole job. + "retries": 3, + "fragment_retries": 5, + "retry_sleep": {"http": 2, "fragment": 2}, } if s["mode"] == "a": diff --git a/backend/app/worker.py b/backend/app/worker.py index f54b719..03b3468 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -219,6 +219,18 @@ def _make_progress_hook(job_id: int, asset_id: int): if now - state["last_db"] < 1.0: return state["last_db"] = now + # yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges. + # Label the phase so the user understands the bar resetting between streams instead of + # seeing a mysterious 95%→5% jump. + info = d.get("info_dict") or {} + vcodec = info.get("vcodec") or "none" + acodec = info.get("acodec") or "none" + if vcodec != "none" and acodec == "none": + phase = "video" + elif acodec != "none" and vcodec == "none": + phase = "audio" + else: + phase = "media" total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0 done = d.get("downloaded_bytes") or 0 pct = int(done * 100 / total) if total else 0 @@ -227,12 +239,25 @@ def _make_progress_hook(job_id: int, asset_id: int): progress=min(pct, 99), speed_bps=int(d["speed"]) if d.get("speed") else None, eta_s=int(d["eta"]) if d.get("eta") else None, - phase="downloading", + phase=phase, ) return hook +def _make_pp_hook(job_id: int): + """Postprocessor hook: surface the ffmpeg merge/convert/extract step so the bar shows a + 'Merging/Processing' label instead of freezing at 100% after the streams finish.""" + + def hook(d: dict) -> None: + if d.get("status") in ("started", "processing"): + pp = d.get("postprocessor") or "" + phase = "merging" if "Merg" in pp else "processing" + _set_job(job_id, phase=phase, speed_bps=None, eta_s=None) + + return hook + + def _staging_dir(asset_id: int) -> Path: return Path(settings.download_root) / ".staging" / f"asset-{asset_id}" @@ -253,6 +278,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe outtmpl = str(staging / "%(id)s.%(ext)s") opts = formats.build_ydl_opts(spec, outtmpl, _make_progress_hook(job_id, asset_id)) + opts["postprocessor_hooks"] = [_make_pp_hook(job_id)] url = service.source_url(source_kind, source_ref) _set_job(job_id, phase="downloading", progress=0) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index c71c3e4..7b1b406 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -36,8 +36,15 @@ const STATUS_CLS: Record = { canceled: "text-muted", }; +const PHASE_KEYS = ["video", "audio", "merging", "processing"]; + function StatusPill({ job }: { job: DownloadJob }) { const { t } = useTranslation(); + // While downloading, show the concrete phase (video / audio / merging) so the user isn't + // confused by the bar resetting between the separate video and audio streams. + if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { + return {t(`downloads.phase.${job.phase}`)}; + } const key = job.expired ? "expired" : job.status; return ( @@ -109,14 +116,23 @@ function DownloadRow({ {running && (
-
-
-
-
- {job.progress}% - {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} - {job.eta_s ? {formatEta(job.eta_s)} : null} -
+ {job.phase === "merging" || job.phase === "processing" ? ( + // No meaningful percentage during ffmpeg — show an indeterminate pulse. +
+
+
+ ) : ( + <> +
+
+
+
+ {job.progress}% + {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} + {job.eta_s ? {formatEta(job.eta_s)} : null} +
+ + )}
)}
diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json index d437c42..4a67e88 100644 --- a/frontend/src/i18n/locales/de/downloads.json +++ b/frontend/src/i18n/locales/de/downloads.json @@ -37,6 +37,12 @@ "canceled": "Abgebrochen", "expired": "Abgelaufen" }, + "phase": { + "video": "Video wird geladen", + "audio": "Audio wird geladen", + "merging": "Zusammenführen", + "processing": "Verarbeitung" + }, "actions": { "pause": "Pause", "resume": "Fortsetzen", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 2fc04f4..576a0f0 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -37,6 +37,12 @@ "canceled": "Canceled", "expired": "Expired" }, + "phase": { + "video": "Downloading video", + "audio": "Downloading audio", + "merging": "Merging", + "processing": "Processing" + }, "actions": { "pause": "Pause", "resume": "Resume", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 764d8f1..ea3866a 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -37,6 +37,12 @@ "canceled": "Megszakítva", "expired": "Lejárt" }, + "phase": { + "video": "Videósáv letöltése", + "audio": "Hangsáv letöltése", + "merging": "Összefűzés", + "processing": "Feldolgozás" + }, "actions": { "pause": "Szünet", "resume": "Folytatás",