feat(downloads): categorize download failures + a readable error dialog (S2)

Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:

- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
  stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
  clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
  with the explanation, the raw text behind a disclosure, and a Retry only for transient
  categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
This commit is contained in:
2026-07-28 01:40:22 +02:00
parent 3f15f35256
commit cdcffb8c91
10 changed files with 337 additions and 5 deletions
+76
View File
@@ -0,0 +1,76 @@
"""Classify a raw yt-dlp / ffmpeg failure string into a stable category code.
The worker stores the raw exception text (truncated) for technical detail, but a raw
`ERROR: [youtube] <id>: Private video. Sign in…` is noise to a user. `classify()` maps that text
to one of a small set of category codes; the frontend turns the code into a localized, human
explanation (and decides whether a retry makes sense). The raw text stays available behind a
"details" disclosure. This is the same string-matching approach the worker already trusts for
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
"""
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
_RULES: tuple[tuple[str, str], ...] = (
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
("sign in to confirm your age", "login_required"),
("confirm your age", "login_required"),
("sign in to confirm you're not a bot", "login_required"),
("not a bot", "login_required"),
("sign in to confirm", "login_required"),
("members-only", "login_required"),
("available to this channel's members", "login_required"),
("join this channel", "login_required"),
("this video is only available to", "login_required"),
# Geo-restricted.
("in your country", "geo_blocked"),
("available in your location", "geo_blocked"),
("not available from your location", "geo_blocked"),
("geo restricted", "geo_blocked"),
# An edit's source download went away — our own wording, and MORE specific than the generic
# "no longer available" below, so it must be matched first.
("source download is no longer available", "source_missing"),
("source file is missing", "source_missing"),
# Private / deleted / removed / terminated.
("private video", "unavailable"),
("video unavailable", "unavailable"),
("no longer available", "unavailable"),
("has been removed", "unavailable"),
("removed by the uploader", "unavailable"),
("account associated with this video has been terminated", "unavailable"),
("this video is not available", "unavailable"),
("this video has been removed", "unavailable"),
# DRM.
("drm protected", "drm"),
("drm-protected", "drm"),
# No usable media format.
("requested format is not available", "format_unavailable"),
("only images are available", "format_unavailable"),
("no video formats found", "format_unavailable"),
("unable to extract", "format_unavailable"),
# Our own post-processing (ffmpeg) step failed.
("ffmpeg failed", "postprocess"),
("ffmpeg stalled", "postprocess"),
("postprocessing", "postprocess"),
("produced no output file", "postprocess"),
("no output file", "postprocess"),
# Network / transport.
("unable to download", "network"),
("http error", "network"),
("timed out", "network"),
("read timed out", "network"),
("temporary failure in name resolution", "network"),
("connection reset", "network"),
("connection refused", "network"),
("failed to resolve", "network"),
)
def classify(msg: str | None) -> str:
"""Return a category code for a raw failure message (``"unknown"`` if nothing matches)."""
if not msg:
return "unknown"
low = msg.lower()
for marker, category in _RULES:
if marker in low:
return category
return "unknown"
+2 -1
View File
@@ -877,7 +877,8 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
speed_bps: Mapped[int | None] = mapped_column(BigInteger)
eta_s: Mapped[int | None] = mapped_column(Integer)
phase: Mapped[str | None] = mapped_column(String(32))
error: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail)
error_code: Mapped[str | None] = mapped_column(String(24)) # category, see downloads.errors.classify
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+2
View File
@@ -81,6 +81,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"eta_s": job.eta_s,
"phase": job.phase,
"error": job.error,
"error_code": job.error_code,
"queue_pos": job.queue_pos,
"created_at": iso(job.created_at),
"source_kind": job.source_kind,
@@ -468,6 +469,7 @@ def resume_download(
if job.status in ("paused", "error"):
job.progress = 0
job.error = None
job.error_code = None
# If the shared asset itself failed, reset it to pending so the worker actually
# re-downloads — otherwise the requeued job would instantly re-inherit the asset's
# stale error (the worker short-circuits a job whose asset is already errored).
+3
View File
@@ -31,6 +31,7 @@ from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import formats, quota, service, storage
from app.models import DownloadJob, MediaAsset
from app.titles import normalize_title
@@ -654,6 +655,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
if job:
job.status = "error"
job.error = msg
job.error_code = dlerrors.classify(msg)
job.phase = None
db.commit()
log.warning("job %s failed: %s", job_id, msg)
@@ -685,6 +687,7 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
if job:
job.status = "error"
job.error = msg
job.error_code = dlerrors.classify(msg)
job.phase = None
db.commit()
log.warning("edit job %s failed: %s", job_id, msg)