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