From d9a7007f34d938668505f3f783c00e55768572e5 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 27 May 2026 02:32:45 -0700 Subject: [PATCH] Classify yt-dlp failures into actionable buckets (2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a job fails, today the user sees a raw stderr line — often something like "ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot" that doesn't tell a non-expert what to do. New src/error_class.rs scans the failed job's log buffer for the nine well-known yt-dlp fingerprints and returns: - an ErrorClass enum (rate-limited, members-only, geo-blocked, not-found, codec-missing, disk-full, network-error, bad-cookies, other) - a one-line human-readable suggested action paired with each class Auto-populated in Job::drain when state transitions to Failed. Exposed on JobSnapshot as error_class + error_hint. Rendered in the web UI Downloads modal (red badge + hint box) and the desktop downloads panel (colored label + hint text). Other (no fingerprint matched) is filtered out of the badge so the existing raw last_line keeps doing the talking for unknown failures. Includes 10 unit tests covering each pattern + the walks-from-end priority case. Co-Authored-By: Claude Opus 4.7 --- src/app.rs | 18 +++ src/downloader.rs | 24 +++- src/error_class.rs | 281 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/web.rs | 17 +++ src/web_ui/index.html | 32 +++-- 6 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 src/error_class.rs diff --git a/src/app.rs b/src/app.rs index ddf4890..2da90d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1486,6 +1486,24 @@ impl App { if !last.is_empty() { ui.label(egui::RichText::new(last).small().monospace()); } + // Failure classification hint. Drawn as a + // colored block so it visually separates + // from the raw stderr last-line above. + if let Some(cls) = job.failure_class { + if cls != crate::error_class::ErrorClass::Other { + ui.horizontal(|ui| { + ui.colored_label( + egui::Color32::from_rgb(220, 110, 110), + format!("⚠ {}", cls.label()), + ); + ui.label( + egui::RichText::new(cls.hint()) + .small() + .color(egui::Color32::from_rgb(240, 180, 180)), + ); + }); + } + } ui.collapsing("output log", |ui| { egui::ScrollArea::vertical() .max_height(180.0) diff --git a/src/downloader.rs b/src/downloader.rs index 755d541..fb0ef77 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -128,6 +128,11 @@ pub struct Job { pub progress: f32, /// Rolling log buffer — capped at [`JOB_LOG_CAP`] lines via O(1) front-pop. pub log: VecDeque, + /// Best-effort classification of the failure, populated when `state` + /// transitions to `Failed`. `None` while running or on success. The UI + /// surfaces the class + a one-line suggested fix from + /// [`crate::error_class`]. + pub failure_class: Option, rx: Receiver, } @@ -144,6 +149,15 @@ impl Job { Msg::Progress(p) => self.progress = p, Msg::Finished(ok) => { self.state = if ok { JobState::Done } else { JobState::Failed }; + // Classify only on the failure transition so the + // classifier doesn't re-run for every poll() call on a + // long-finished job. The log is already in `self.log` + // by this point since we drained Line messages above. + if !ok && self.failure_class.is_none() { + self.failure_class = Some(crate::error_class::classify( + self.log.iter().map(|s| s.as_str()), + )); + } } } } @@ -594,7 +608,15 @@ impl Downloader { let _ = tx.send(Msg::Finished(ok)); }); - self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: VecDeque::new(), rx }); + self.jobs.push(Job { + url, + label, + state: JobState::Running, + progress: 0.0, + log: VecDeque::new(), + failure_class: None, + rx, + }); } /// Drain pending messages from all job threads and promote queued jobs. diff --git a/src/error_class.rs b/src/error_class.rs new file mode 100644 index 0000000..4b3ae79 --- /dev/null +++ b/src/error_class.rs @@ -0,0 +1,281 @@ +//! Classification of yt-dlp / network failures into actionable buckets. +//! +//! When a job fails, the user sees the last line of stderr — usually a +//! cryptic yt-dlp error like `ERROR: [youtube] dQw4w9WgXcQ: Sign in to +//! confirm you're not a bot. ...`. That doesn't tell a non-expert what to +//! actually *do*. This module pattern-matches the well-known fingerprints +//! and returns both: +//! +//! - an [`ErrorClass`] for the UI to render as a colored badge, +//! - and a short human-readable `hint` string with the suggested fix. +//! +//! The classifier is intentionally conservative: when no pattern matches, +//! it returns [`ErrorClass::Other`] and lets the existing raw log do the +//! talking. False positives would be worse than no classification. + +use serde::Serialize; + +/// One of a handful of well-known yt-dlp failure modes, or `Other` when +/// the log doesn't match any pattern. The string serialisation is what +/// the JSON API + JS UI consumes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ErrorClass { + /// HTTP 429 / "Too Many Requests", or YouTube's "Sign in to confirm + /// you're not a bot" rate-limit cookie wall. Suggest cookies or wait. + RateLimited, + /// Video is members-only / private / behind a paywall. Needs cookies + /// from a logged-in browser session with access. + MembersOnly, + /// Geo-blocked in the user's region. Needs a proxy or VPN. + Geoblocked, + /// Video unavailable / removed / deleted / copyright-strike. + NotFound, + /// Required codec (ffmpeg / decoder) missing on the system. + CodecMissing, + /// Out of disk space mid-download. + DiskFull, + /// Network-level failure (timeout, DNS, connection refused) not + /// otherwise classified. + NetworkError, + /// Cookies file exists but yt-dlp rejected it (expired session etc.). + BadCookies, + /// Catchall. The existing log is the user's only hint. + Other, +} + +impl ErrorClass { + /// Short label for badges and JSON. Human-facing but kept terse. + pub fn label(self) -> &'static str { + match self { + ErrorClass::RateLimited => "rate-limited", + ErrorClass::MembersOnly => "members-only", + ErrorClass::Geoblocked => "geo-blocked", + ErrorClass::NotFound => "not found", + ErrorClass::CodecMissing => "codec missing", + ErrorClass::DiskFull => "disk full", + ErrorClass::NetworkError => "network error", + ErrorClass::BadCookies => "bad cookies", + ErrorClass::Other => "error", + } + } + + /// One-sentence suggested action. Reads as "do X to fix this." + pub fn hint(self) -> &'static str { + match self { + ErrorClass::RateLimited => + "YouTube is rate-limiting you. Add cookies from a logged-in browser session (Settings → Cookies), or wait 10–60 minutes and retry.", + ErrorClass::MembersOnly => + "This video requires a logged-in session with access (members-only, private, or paid). Update cookies.txt from an account that can view it.", + ErrorClass::Geoblocked => + "This video is not available in your region. Try a different cookies.txt from an unblocked region, or use a VPN.", + ErrorClass::NotFound => + "The video appears to have been removed by the uploader or platform. Nothing to download.", + ErrorClass::CodecMissing => + "A required codec or tool is missing. Make sure ffmpeg is installed and on your PATH.", + ErrorClass::DiskFull => + "The destination disk is full. Free up space and retry — yt-dlp resumes from where it stopped.", + ErrorClass::NetworkError => + "Network issue reaching the platform. Check connectivity / firewall and retry.", + ErrorClass::BadCookies => + "yt-dlp rejected the cookies file. The session probably expired — export a fresh cookies.txt from your browser.", + ErrorClass::Other => "", + } + } +} + +/// Classify a failed yt-dlp job by scanning its log buffer. +/// +/// Walks the most recent lines first because yt-dlp's terminal error is +/// usually the final or near-final line; earlier output may contain +/// unrelated noise. Returns [`ErrorClass::Other`] when no fingerprint +/// matches. +pub fn classify<'a, I>(log_lines: I) -> ErrorClass +where + I: IntoIterator, + I::IntoIter: DoubleEndedIterator, +{ + // Collect into a Vec because we want to walk from the end. Logs are + // capped at ~800 lines elsewhere so this is fine. + let lines: Vec<&str> = log_lines.into_iter().collect(); + for line in lines.iter().rev() { + let l = line.to_ascii_lowercase(); + + // ── Rate limits / bot challenges ───────────────────────────── + if l.contains("http error 429") + || l.contains("too many requests") + || l.contains("sign in to confirm you") + || l.contains("sign in to confirm your age") + || l.contains("ratelimit") + || l.contains("rate limit") + { + return ErrorClass::RateLimited; + } + + // ── Access-controlled content ───────────────────────────────── + if l.contains("members-only") + || l.contains("join this channel") + || l.contains("private video") + || l.contains("requires payment") + || l.contains("requires purchase") + { + return ErrorClass::MembersOnly; + } + + // ── Geo blocking ────────────────────────────────────────────── + // Many fingerprints share the suffix "in your country"; that's the + // strongest signal so we check it directly. "in your region" covers + // the corporate-region variant some platforms use. + if l.contains("in your country") + || l.contains("in your region") + || l.contains("geo restricted") + || l.contains("video unavailable in your") + { + return ErrorClass::Geoblocked; + } + + // ── Removed / unavailable ───────────────────────────────────── + // Distinguish from geo above: the geo check matched "in your + // country" already. A bare "video unavailable" with no region + // qualifier means the upload is gone. + if l.contains("this video has been removed") + || l.contains("video has been deleted") + || l.contains("account has been terminated") + || (l.contains("video unavailable") && !l.contains("in your")) + || (l.contains("http error 404") && !l.contains("playlist")) + { + return ErrorClass::NotFound; + } + + // ── Local toolchain ─────────────────────────────────────────── + if l.contains("ffmpeg not found") + || l.contains("ffprobe not found") + || l.contains("you have requested merging of multiple formats") + || l.contains("postprocessing: ffmpeg") + { + return ErrorClass::CodecMissing; + } + + // ── Disk full ──────────────────────────────────────────────── + if l.contains("no space left on device") + || l.contains("disk full") + || l.contains("write error") + && (l.contains("space") || l.contains("enospc")) + { + return ErrorClass::DiskFull; + } + + // ── Cookies rejected ───────────────────────────────────────── + if l.contains("invalid cookies") + || l.contains("cookies file") + && (l.contains("expired") || l.contains("invalid") || l.contains("malformed")) + { + return ErrorClass::BadCookies; + } + + // ── Network ────────────────────────────────────────────────── + if l.contains("name or service not known") + || l.contains("temporary failure in name resolution") + || l.contains("connection refused") + || l.contains("connection reset") + || l.contains("connection timed out") + || l.contains("network is unreachable") + || l.contains("ssl: certificate") + || l.contains("read timed out") + { + return ErrorClass::NetworkError; + } + } + ErrorClass::Other +} + +#[cfg(test)] +mod tests { + use super::*; + + fn classify_str(s: &str) -> ErrorClass { + classify(s.lines()) + } + + #[test] + fn detects_rate_limit_429() { + assert_eq!( + classify_str("ERROR: HTTP Error 429: Too Many Requests"), + ErrorClass::RateLimited + ); + } + + #[test] + fn detects_sign_in_to_confirm() { + assert_eq!( + classify_str("ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies-from-browser..."), + ErrorClass::RateLimited + ); + } + + #[test] + fn detects_members_only() { + assert_eq!( + classify_str("ERROR: [youtube] abc: Join this channel to get access to members-only content."), + ErrorClass::MembersOnly + ); + } + + #[test] + fn detects_video_removed() { + assert_eq!( + classify_str("ERROR: [youtube] xyz: Video unavailable. This video has been removed by the uploader."), + ErrorClass::NotFound + ); + } + + #[test] + fn detects_geo_block() { + assert_eq!( + classify_str("ERROR: The uploader has not made this video available in your country."), + ErrorClass::Geoblocked + ); + } + + #[test] + fn detects_disk_full() { + assert_eq!( + classify_str("ERROR: unable to write data: [Errno 28] No space left on device"), + ErrorClass::DiskFull + ); + } + + #[test] + fn detects_network() { + assert_eq!( + classify_str("ERROR: Unable to download webpage: "), + ErrorClass::NetworkError + ); + } + + #[test] + fn detects_ffmpeg_missing() { + assert_eq!( + classify_str("ERROR: ffmpeg not found. The downloaded file cannot be merged."), + ErrorClass::CodecMissing + ); + } + + #[test] + fn returns_other_when_no_match() { + assert_eq!( + classify_str("ERROR: something weird happened that we have no fingerprint for"), + ErrorClass::Other + ); + } + + #[test] + fn walks_from_end_first() { + // Earlier noise about ffmpeg shouldn't override the actual terminal + // failure on the last line. + let log = "[info] some ffmpeg postprocessing thing\n\ + [download] 50% done\n\ + ERROR: HTTP Error 429: Too Many Requests"; + assert_eq!(classify_str(log), ErrorClass::RateLimited); + } +} diff --git a/src/main.rs b/src/main.rs index e89794d..dd1d5f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod config; mod database; mod download_options; mod downloader; +mod error_class; mod library; mod maintenance; mod platform; diff --git a/src/web.rs b/src/web.rs index 66d9b25..90d47b0 100644 --- a/src/web.rs +++ b/src/web.rs @@ -61,6 +61,17 @@ pub struct JobSnapshot { pub state: &'static str, pub progress: f32, pub last_line: String, + /// Classification of the failure, if `state == "failed"`. One of + /// `rate-limited`, `members-only`, `geo-blocked`, `not-found`, + /// `codec-missing`, `disk-full`, `network-error`, `bad-cookies`, `other`, + /// or `null` while still running / on success. Drives the suggested + /// action hint in the UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_class: Option, + /// Human-readable one-line suggested action paired with `error_class`. + /// Empty when `error_class` is `Other` or `None`. + #[serde(skip_serializing_if = "str::is_empty")] + pub error_hint: &'static str, } /// All mutable state shared across axum handlers via `Arc`. @@ -145,6 +156,12 @@ impl WebState { }, progress: j.progress, last_line: j.log.back().cloned().unwrap_or_default(), + // Skip `Other` so the badge doesn't get a useless generic + // label — the raw log line is still shown for that case. + error_class: j.failure_class.filter(|c| + *c != crate::error_class::ErrorClass::Other + ), + error_hint: j.failure_class.map(|c| c.hint()).unwrap_or(""), }) .collect() } diff --git a/src/web_ui/index.html b/src/web_ui/index.html index dcb97bf..38d4033 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -113,9 +113,15 @@ #dl-btn.has-active{color:var(--accent)} #dl-badge{position:absolute;top:-4px;right:-4px;background:var(--accent);color:#000;font-size:10px;font-weight:700;border-radius:9px;padding:1px 5px;min-width:16px;text-align:center;line-height:1.2;display:none} #dl-btn.has-active #dl-badge{display:inline-block} - .job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;border-bottom:1px solid var(--border);flex-wrap:wrap;min-width:0} + .job-row{border-bottom:1px solid var(--border)} + .job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;flex-wrap:wrap;min-width:0} + .job-row .job{border-bottom:none} .badge{font-weight:700;min-width:48px;flex-shrink:0} .badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171} + /* Error-class badge sits next to the state badge. Subtle so the actual + hint below carries the user's attention. */ + .err-class{font-size:10px;background:#7f1d1d;color:#fecaca;border-radius:3px;padding:1px 6px;text-transform:uppercase;letter-spacing:.4px;flex-shrink:0} + .job-hint{font-size:11px;color:#fecaca;background:rgba(127,29,29,.18);border-left:2px solid #f87171;padding:6px 14px 6px 12px;margin:0 14px 6px} progress{flex:1;height:5px;accent-color:var(--accent);min-width:40px} footer{background:var(--panel);border-top:1px solid var(--border);padding:8px 12px;display:flex;gap:8px;align-items:center;flex-shrink:0} footer input{flex:1;min-width:0;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 9px;border-radius:4px;font-size:13px} @@ -1639,12 +1645,24 @@ function renderJobs(jobs,queued,maxConcurrent){ const limitNote=maxConcurrent>0?`
max ${maxConcurrent} concurrent
`:''; body.innerHTML=hdr+jobs.map((j,i)=>{ const dismiss=j.state!=='running'?``:''; - return `
- ${j.state} - ${esc(j.label)} — ${esc(j.url)} - ${j.state==='running'?``:''} - ${esc(j.last_line)} - ${dismiss} + // Failed-job hint: when the classifier matched a known pattern, show + // the suggested action below the row in a subtle box. Unclassified + // failures (error_class === undefined) fall back to just the raw + // last_line we already render in the badge row. + const errBadge=j.state==='failed'&&j.error_class + ? `${esc(j.error_class)}` : ''; + const errHint=j.state==='failed'&&j.error_hint + ? `
${esc(j.error_hint)}
` : ''; + return `
+
+ ${j.state} + ${errBadge} + ${esc(j.label)} — ${esc(j.url)} + ${j.state==='running'?``:''} + ${esc(j.last_line)} + ${dismiss} +
+ ${errHint}
`; }).join('')+queuedHtml+limitNote; }