Download manager: cancel/retry/queue controls + dedup off-switch

Download management UI (web + desktop):
- Cancel a running job (SIGKILL, marked "cancelled" so it isn't auto-retried
  and shows a distinct state rather than a misleading error class)
- Cancel a still-queued job before it starts
- Manually retry a failed/cancelled job (fresh auto-retry budget)
- Live speed · ETA · % on running jobs, parsed from the yt-dlp progress line
- Expandable full per-job log fetched on demand
New endpoints: POST /api/jobs/:idx/{cancel,retry}, GET /api/jobs/:idx/log,
DELETE /api/queued/:idx.

Perceptual-dedup off-switch:
- backup.dedup_enabled (default true) hard-disables the "similar content"
  scan in both UIs; the web scan endpoint returns {disabled:true} and the
  desktop scan no-ops with a note. Wired through all five settings touchpoints.

Docs: README seeking note + ROADMAP recently-shipped updates for the prior
seekable-playback / federation / auto-tagging work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-15 16:35:05 -07:00
parent c3ff9121f6
commit 9ed62935f5
7 changed files with 305 additions and 33 deletions

View file

@ -58,10 +58,13 @@ use crate::maintenance;
pub struct JobSnapshot {
pub label: String,
pub url: String,
/// One of `"running"`, `"done"`, or `"failed"`.
/// One of `"running"`, `"done"`, `"failed"`, or `"cancelled"`.
pub state: &'static str,
pub progress: f32,
pub last_line: String,
/// Whether a manual "Retry" can re-issue this job (it has the captured
/// inputs). False for running jobs, live recordings, and self-update.
pub can_retry: bool,
/// 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`,
@ -205,16 +208,25 @@ impl WebState {
state: match j.state {
JobState::Running => "running",
JobState::Done => "done",
// A user cancel lands as Failed internally; surface it as a
// distinct state so the UI doesn't show an error class.
JobState::Failed if j.cancelled => "cancelled",
JobState::Failed => "failed",
},
progress: j.progress,
last_line: j.log.back().cloned().unwrap_or_default(),
can_retry: j.state != JobState::Running && j.has_retry_spec(),
// 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(""),
// label — the raw log line is still shown for that case. A
// cancelled job has no meaningful error class either.
error_class: if j.cancelled { None } else {
j.failure_class.filter(|c|
*c != crate::error_class::ErrorClass::Other
)
},
error_hint: if j.cancelled { "" } else {
j.failure_class.map(|c| c.hint()).unwrap_or("")
},
})
.collect()
}
@ -424,6 +436,9 @@ struct SettingsPayload {
/// live in each channel's DownloadOptions, not here.
#[serde(default)]
fetch_comments: bool,
/// Whether the perceptual "similar content" dedup scan is enabled.
#[serde(default = "crate::config::default_true")]
dedup_enabled: bool,
/// Global [convert] settings, round-tripped on GET + POST.
#[serde(default)]
convert_mode: String,
@ -1527,6 +1542,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let player_clients_out = cfg.backup.youtube_player_clients.clone();
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
let fetch_comments_out = cfg.backup.fetch_comments;
let dedup_enabled_out = cfg.backup.dedup_enabled;
let convert = cfg.convert.clone();
drop(cfg);
@ -1567,6 +1583,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
youtube_player_clients: player_clients_out.clone(),
sponsorblock_mode: sponsorblock_out.clone(),
fetch_comments: fetch_comments_out,
dedup_enabled: dedup_enabled_out,
convert_mode: convert.mode.clone(),
convert_crf: convert.crf,
convert_preset: convert.preset.clone(),
@ -1614,6 +1631,7 @@ async fn post_settings(
cfg.backup.youtube_player_clients = body.youtube_player_clients.trim().to_string();
cfg.backup.sponsorblock_mode = body.sponsorblock_mode.trim().to_string();
cfg.backup.fetch_comments = body.fetch_comments;
cfg.backup.dedup_enabled = body.dedup_enabled;
// Global format-conversion defaults.
cfg.convert.mode = body.convert_mode.trim().to_string();
cfg.convert.crf = body.convert_crf;
@ -1637,6 +1655,7 @@ async fn post_settings(
let player_clients_out = cfg.backup.youtube_player_clients.clone();
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
let fetch_comments_out = cfg.backup.fetch_comments;
let dedup_enabled_out = cfg.backup.dedup_enabled;
let convert = cfg.convert.clone();
drop(cfg);
@ -1652,6 +1671,7 @@ async fn post_settings(
dl.youtube_player_clients = player_clients_out.clone();
dl.sponsorblock_mode = sponsorblock_out.clone();
dl.fetch_comments = fetch_comments_out;
dl.dedup_enabled = dedup_enabled_out;
dl.convert_defaults = convert.clone();
}
@ -1710,6 +1730,7 @@ async fn post_settings(
youtube_player_clients: player_clients_out.clone(),
sponsorblock_mode: sponsorblock_out.clone(),
fetch_comments: fetch_comments_out,
dedup_enabled: dedup_enabled_out,
convert_mode: convert.mode.clone(),
convert_crf: convert.crf,
convert_preset: convert.preset.clone(),
@ -1950,6 +1971,54 @@ async fn post_remove_job(
(StatusCode::OK, "removed")
}
/// `POST /api/jobs/:idx/cancel` — SIGKILL a running job and mark it cancelled.
async fn post_cancel_job(
State(state): State<Arc<WebState>>,
Path(idx): Path<usize>,
) -> impl IntoResponse {
if state.downloader.lock_recover().cancel_job(idx) {
(StatusCode::OK, "cancelled")
} else {
(StatusCode::CONFLICT, "not a running job")
}
}
/// `POST /api/jobs/:idx/retry` — re-issue a finished/failed/cancelled job,
/// removing the old row. 409 if it's running or carries no retry inputs.
async fn post_retry_job(
State(state): State<Arc<WebState>>,
Path(idx): Path<usize>,
) -> impl IntoResponse {
if state.downloader.lock_recover().retry_job(idx) {
(StatusCode::OK, "retrying")
} else {
(StatusCode::CONFLICT, "not retryable")
}
}
/// `DELETE /api/jobs/queued/:idx` — drop a not-yet-started queued job.
async fn delete_queued_job(
State(state): State<Arc<WebState>>,
Path(idx): Path<usize>,
) -> impl IntoResponse {
if state.downloader.lock_recover().cancel_queued(idx) {
(StatusCode::OK, "removed")
} else {
(StatusCode::NOT_FOUND, "no such queued job")
}
}
/// `GET /api/jobs/:idx/log` — the full captured log buffer for one job.
async fn get_job_log(
State(state): State<Arc<WebState>>,
Path(idx): Path<usize>,
) -> impl IntoResponse {
match state.downloader.lock_recover().job_log(idx) {
Some(lines) => Json(serde_json::json!({ "lines": lines })).into_response(),
None => (StatusCode::NOT_FOUND, "no such job").into_response(),
}
}
async fn get_chapters(
State(state): State<Arc<WebState>>,
Path(video_id): Path<String>,
@ -2368,6 +2437,12 @@ async fn get_feed_info(State(state): State<Arc<WebState>>) -> impl IntoResponse
/// (cached by mtime), then groups by visual similarity. Returns immediately;
/// poll `/api/maintenance/dedup/status` for progress + results.
async fn post_dedup_scan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
// Honor the global off-switch (backup.dedup_enabled).
if !state.downloader.lock_recover().dedup_enabled {
return Json(serde_json::json!({
"started": false, "running": false, "disabled": true
}));
}
if state.dedup.running.swap(true, Ordering::SeqCst) {
return Json(serde_json::json!({ "started": false, "running": true }));
}
@ -3068,6 +3143,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
downloader.youtube_player_clients = config.backup.youtube_player_clients.clone();
downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
downloader.fetch_comments = config.backup.fetch_comments;
downloader.dedup_enabled = config.backup.dedup_enabled;
downloader.convert_defaults = config.convert.clone();
let music_root = downloader.music_root();
let _ = std::fs::create_dir_all(&music_root);
@ -3215,6 +3291,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/rescan", post(post_rescan))
.route("/api/jobs/clear", post(post_clear_jobs))
.route("/api/jobs/:idx", axum::routing::delete(post_remove_job))
.route("/api/jobs/:idx/cancel", post(post_cancel_job))
.route("/api/jobs/:idx/retry", post(post_retry_job))
.route("/api/jobs/:idx/log", get(get_job_log))
.route("/api/queued/:idx", axum::routing::delete(delete_queued_job))
.route("/api/description/:id", get(get_description))
.route("/api/chapters/:id", get(get_chapters))
.route("/api/comments/:id", get(get_comments))