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:
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user