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

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