Classify yt-dlp failures into actionable buckets (2.3)

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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-27 02:32:45 -07:00
parent a8b14f250a
commit d9a7007f34
6 changed files with 365 additions and 8 deletions

View file

@ -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)

View file

@ -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<String>,
/// 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<crate::error_class::ErrorClass>,
rx: Receiver<Msg>,
}
@ -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.

281
src/error_class.rs Normal file
View file

@ -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 1060 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<Item = &'a str>,
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: <urlopen error [Errno -3] Temporary failure in name resolution>"),
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);
}
}

View file

@ -18,6 +18,7 @@ mod config;
mod database;
mod download_options;
mod downloader;
mod error_class;
mod library;
mod maintenance;
mod platform;

View file

@ -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<crate::error_class::ErrorClass>,
/// 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<WebState>`.
@ -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()
}

View file

@ -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?`<div style="font-size:11px;color:var(--muted);padding:6px 14px">max ${maxConcurrent} concurrent</div>`:'';
body.innerHTML=hdr+jobs.map((j,i)=>{
const dismiss=j.state!=='running'?`<button onclick="removeJob(${i})" style="font-size:11px;padding:1px 6px"></button>`:'';
return `<div class="job">
// 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
? `<span class="err-class" title="${esc(j.error_hint||'')}">${esc(j.error_class)}</span>` : '';
const errHint=j.state==='failed'&&j.error_hint
? `<div class="job-hint">${esc(j.error_hint)}</div>` : '';
return `<div class="job-row">
<div class="job">
<span class="badge ${j.state}">${j.state}</span>
${errBadge}
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
${j.state==='running'?`<progress value="${j.progress}" max="1"></progress>`:''}
<span class="last-line" style="font-size:11px;color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>
${dismiss}
</div>
${errHint}
</div>`;
}).join('')+queuedHtml+limitNote;
}