feat(downloads): clear multi-phase download progress + transient-error retries
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.
This commit is contained in:
parent
c6ceaea899
commit
b200f61642
6 changed files with 73 additions and 9 deletions
|
|
@ -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)
|
"writethumbnail": True, # kept for the poster.jpg sidecar (and embed, if requested)
|
||||||
"progress_hooks": [progress_hook],
|
"progress_hooks": [progress_hook],
|
||||||
"postprocessors": [],
|
"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":
|
if s["mode"] == "a":
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,18 @@ def _make_progress_hook(job_id: int, asset_id: int):
|
||||||
if now - state["last_db"] < 1.0:
|
if now - state["last_db"] < 1.0:
|
||||||
return
|
return
|
||||||
state["last_db"] = now
|
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
|
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||||
done = d.get("downloaded_bytes") or 0
|
done = d.get("downloaded_bytes") or 0
|
||||||
pct = int(done * 100 / total) if total else 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),
|
progress=min(pct, 99),
|
||||||
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
||||||
eta_s=int(d["eta"]) if d.get("eta") else None,
|
eta_s=int(d["eta"]) if d.get("eta") else None,
|
||||||
phase="downloading",
|
phase=phase,
|
||||||
)
|
)
|
||||||
|
|
||||||
return hook
|
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:
|
def _staging_dir(asset_id: int) -> Path:
|
||||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
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")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
opts = formats.build_ydl_opts(spec, outtmpl, _make_progress_hook(job_id, asset_id))
|
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)
|
url = service.source_url(source_kind, source_ref)
|
||||||
|
|
||||||
_set_job(job_id, phase="downloading", progress=0)
|
_set_job(job_id, phase="downloading", progress=0)
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,15 @@ const STATUS_CLS: Record<string, string> = {
|
||||||
canceled: "text-muted",
|
canceled: "text-muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PHASE_KEYS = ["video", "audio", "merging", "processing"];
|
||||||
|
|
||||||
function StatusPill({ job }: { job: DownloadJob }) {
|
function StatusPill({ job }: { job: DownloadJob }) {
|
||||||
const { t } = useTranslation();
|
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 <span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>;
|
||||||
|
}
|
||||||
const key = job.expired ? "expired" : job.status;
|
const key = job.expired ? "expired" : job.status;
|
||||||
return (
|
return (
|
||||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||||
|
|
@ -109,6 +116,13 @@ function DownloadRow({
|
||||||
</div>
|
</div>
|
||||||
{running && (
|
{running && (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
|
{job.phase === "merging" || job.phase === "processing" ? (
|
||||||
|
// No meaningful percentage during ffmpeg — show an indeterminate pulse.
|
||||||
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
<div className="h-full bg-accent transition-all" style={{ width: `${job.progress}%` }} />
|
<div className="h-full bg-accent transition-all" style={{ width: `${job.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,6 +131,8 @@ function DownloadRow({
|
||||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@
|
||||||
"canceled": "Abgebrochen",
|
"canceled": "Abgebrochen",
|
||||||
"expired": "Abgelaufen"
|
"expired": "Abgelaufen"
|
||||||
},
|
},
|
||||||
|
"phase": {
|
||||||
|
"video": "Video wird geladen",
|
||||||
|
"audio": "Audio wird geladen",
|
||||||
|
"merging": "Zusammenführen",
|
||||||
|
"processing": "Verarbeitung"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Fortsetzen",
|
"resume": "Fortsetzen",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@
|
||||||
"canceled": "Canceled",
|
"canceled": "Canceled",
|
||||||
"expired": "Expired"
|
"expired": "Expired"
|
||||||
},
|
},
|
||||||
|
"phase": {
|
||||||
|
"video": "Downloading video",
|
||||||
|
"audio": "Downloading audio",
|
||||||
|
"merging": "Merging",
|
||||||
|
"processing": "Processing"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@
|
||||||
"canceled": "Megszakítva",
|
"canceled": "Megszakítva",
|
||||||
"expired": "Lejárt"
|
"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": {
|
"actions": {
|
||||||
"pause": "Szünet",
|
"pause": "Szünet",
|
||||||
"resume": "Folytatás",
|
"resume": "Folytatás",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue