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
@@ -0,0 +1,26 @@
"""add download_jobs.error_code — a category for a failed download (S2)
The worker already stores the raw yt-dlp/ffmpeg failure text in `download_jobs.error`. This adds a
small category code (`unavailable` / `login_required` / `geo_blocked` / `format_unavailable` /
`drm` / `postprocess` / `source_missing` / `network` / `unknown`) computed by
`app.downloads.errors.classify`, so the UI can show a clean localized explanation and decide whether
a retry makes sense, keeping the raw text as a technical-detail disclosure. Nullable; only set on the
error path. Forward-only — an existing errored row simply has NULL until its next failure/retry.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0061_dl_job_error_code"
down_revision: Union[str, None] = "0060_plex_jsonb_null_normalize"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("download_jobs", sa.Column("error_code", sa.String(length=24), nullable=True))
def downgrade() -> None:
op.drop_column("download_jobs", "error_code")
+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)
+47
View File
@@ -0,0 +1,47 @@
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
from app.downloads.errors import classify
def test_real_ytdlp_messages_map_to_categories():
# Verbatim-shaped yt-dlp / ffmpeg output → the category the UI explains.
cases = {
"ERROR: [youtube] xxxxxxxxxxx: Video unavailable": "unavailable",
"ERROR: [youtube] abc: Private video. Sign in if you've been granted access": "unavailable",
"ERROR: [youtube] abc: This video is no longer available because the uploader has closed their "
"account": "unavailable",
"ERROR: [youtube] abc: Sign in to confirm your age. This video may be inappropriate": "login_required",
"ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required",
"ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required",
"ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked",
"ERROR: [youtube] abc: Requested format is not available": "format_unavailable",
"ERROR: [youtube] abc: Only images are available for download": "format_unavailable",
"ERROR: [generic] This video is DRM protected": "drm",
"ffmpeg failed (1): Conversion failed!": "postprocess",
"The source download is no longer available.": "source_missing",
"The source file is missing.": "source_missing",
"ERROR: unable to download video data: HTTP Error 403: Forbidden": "network",
"ERROR: Unable to download webpage: <urlopen error timed out>": "network",
}
for msg, expected in cases.items():
assert classify(msg) == expected, f"{msg!r}{classify(msg)!r}, expected {expected!r}"
def test_unmatched_and_empty_are_unknown():
assert classify("some totally novel failure text") == "unknown"
assert classify("") == "unknown"
assert classify(None) == "unknown"
def test_matching_is_case_insensitive():
assert classify("VIDEO UNAVAILABLE") == "unavailable"
assert classify("drm protected") == "drm"
def test_login_beats_generic_unavailable_when_both_present():
# A members-only video also reads as "unavailable" to a signed-out client; the sign-in cause is
# the more useful, more specific one, so it must win (rule order guards this).
msg = (
"ERROR: [youtube] abc: Join this channel to get access to members-only content. "
"This video is unavailable"
)
assert classify(msg) == "login_required"
+93 -3
View File
@@ -210,7 +210,15 @@ function ChannelLine({ job }: { job: DownloadJob }) {
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
}
function DownloadRow({ job, children }: { job: DownloadJob; children?: React.ReactNode }) {
function DownloadRow({
job,
onShowError,
children,
}: {
job: DownloadJob;
onShowError?: (job: DownloadJob) => void;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const running = job.status === "running";
return (
@@ -242,7 +250,16 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
</span>
) : null}
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
{job.status === "error" ? (
<button
onClick={() => onShowError?.(job)}
className="text-red-400 hover:text-red-300 inline-flex items-center gap-1 truncate"
title={t("downloads.errors.fail.title")}
>
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</button>
) : null}
</div>
{job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => (
@@ -383,6 +400,71 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
);
}
// --- Error detail modal ---------------------------------------------------------------------
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
// where a retry can't help — the queue row still keeps its own retry control regardless.
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
function ErrorModal({
job,
onRetry,
onClose,
}: {
job: DownloadJob;
onRetry: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const code = job.error_code || "unknown";
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
{job.error && (
<div>
<button
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted hover:text-fg transition"
>
{t("downloads.errors.fail.detailsToggle")}
</button>
{showRaw && (
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap break-words max-h-40 text-muted">
{job.error}
</pre>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
{retryable && (
<button
onClick={() => {
onRetry();
onClose();
}}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("downloads.actions.retry")}
</button>
)}
<button
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("common.close")}
</button>
</div>
</div>
</Modal>
);
}
// --- Usage bar ------------------------------------------------------------------------------
function UsageBar() {
@@ -688,6 +770,7 @@ export default function DownloadCenter() {
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
const sharedQ = useQuery({
@@ -818,7 +901,7 @@ export default function DownloadCenter() {
</div>
<div className="space-y-2">
{queue.map((job) => (
<DownloadRow key={job.id} job={job}>
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })}
@@ -983,6 +1066,13 @@ export default function DownloadCenter() {
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
onClose={() => setErrorJob(null)}
/>
)}
</Suspense>
</div>
</>
@@ -206,6 +206,49 @@
"empty_edit": "Choose a trim range or crop area first.",
"source_not_ready": "The source download isn't ready to edit.",
"generic": "That edit couldn't be created."
},
"fail": {
"title": "Download failed",
"detailsLink": "details",
"detailsToggle": "Technical details",
"cat": {
"unavailable": {
"short": "Video unavailable",
"body": "This video is private, deleted, or otherwise no longer available at the source, so it can't be downloaded."
},
"login_required": {
"short": "Sign-in required",
"body": "This video needs a signed-in, age-verified, or members-only account to view, so it can't be downloaded here."
},
"geo_blocked": {
"short": "Region-blocked",
"body": "The source blocks this video in the server's region, so it can't be downloaded."
},
"format_unavailable": {
"short": "No downloadable format",
"body": "No downloadable video format was offered for this link — it may be images-only or an unsupported page."
},
"drm": {
"short": "DRM-protected",
"body": "This video is DRM-protected and cannot be downloaded."
},
"postprocess": {
"short": "Processing failed",
"body": "The video downloaded but a processing step (merge or convert) failed. A retry often fixes this."
},
"source_missing": {
"short": "Source unavailable",
"body": "The original download this clip is based on is no longer available."
},
"network": {
"short": "Network error",
"body": "A network error interrupted the download. A retry usually fixes this."
},
"unknown": {
"short": "Download failed",
"body": "Something went wrong while downloading this item. The technical details below may help."
}
}
}
}
}
@@ -206,6 +206,49 @@
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.",
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
"generic": "Ezt a vágást nem sikerült létrehozni."
},
"fail": {
"title": "A letöltés nem sikerült",
"detailsLink": "részletek",
"detailsToggle": "Technikai részletek",
"cat": {
"unavailable": {
"short": "A videó nem elérhető",
"body": "Ez a videó privát, törölt, vagy más okból már nem elérhető a forrásnál, ezért nem tölthető le."
},
"login_required": {
"short": "Bejelentkezés szükséges",
"body": "Ehhez a videóhoz bejelentkezett, kor-ellenőrzött vagy tagsági fiók kell, ezért itt nem tölthető le."
},
"geo_blocked": {
"short": "Régió szerint tiltva",
"body": "A forrás a szerver régiójában letiltja ezt a videót, ezért nem tölthető le."
},
"format_unavailable": {
"short": "Nincs letölthető formátum",
"body": "Ehhez a linkhez nem kínált fel letölthető videóformátumot — lehet csak képes, vagy nem támogatott oldal."
},
"drm": {
"short": "DRM-védett",
"body": "Ez a videó DRM-védett, ezért nem tölthető le."
},
"postprocess": {
"short": "A feldolgozás megszakadt",
"body": "A videó letöltődött, de egy feldolgozási lépés (összefűzés vagy konvertálás) meghiúsult. Újrapróbálásra gyakran megjavul."
},
"source_missing": {
"short": "A forrás nem elérhető",
"body": "Az eredeti letöltés, amelyből ez a vágás készült, már nem érhető el."
},
"network": {
"short": "Hálózati hiba",
"body": "Hálózati hiba szakította meg a letöltést. Újrapróbálásra általában megjavul."
},
"unknown": {
"short": "A letöltés nem sikerült",
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
}
}
}
}
}
+2 -1
View File
@@ -69,7 +69,8 @@ export interface DownloadJob {
speed_bps: number | null;
eta_s: number | null;
phase: string | null;
error: string | null;
error: string | null; // raw yt-dlp/ffmpeg text (technical detail)
error_code: string | null; // failure category, see backend downloads.errors.classify
queue_pos: number;
created_at: string | null;
source_kind: string;