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:
parent
c3ff9121f6
commit
9ed62935f5
7 changed files with 305 additions and 33 deletions
|
|
@ -163,6 +163,10 @@ pub struct Job {
|
|||
/// On the resulting failure we force a retryable class so auto-retry
|
||||
/// re-queues it — a hang is transient, worth one more try.
|
||||
watchdog_killed: bool,
|
||||
/// True when the user explicitly cancelled this job (vs. a genuine
|
||||
/// failure). Suppresses auto-retry and is surfaced as a distinct
|
||||
/// "cancelled" state in the UI rather than a misleading error class.
|
||||
pub cancelled: bool,
|
||||
rx: Receiver<Msg>,
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +240,13 @@ const AUTO_RETRY_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(
|
|||
const HANG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
impl Job {
|
||||
/// Whether this job carries the inputs needed for a manual retry (download
|
||||
/// jobs do; live recordings, self-update, and synthetic preflight failures
|
||||
/// don't). Drives the UI's "Retry" button.
|
||||
pub fn has_retry_spec(&self) -> bool {
|
||||
self.retry_spec.is_some()
|
||||
}
|
||||
|
||||
fn drain(&mut self) {
|
||||
while let Ok(msg) = self.rx.try_recv() {
|
||||
match msg {
|
||||
|
|
@ -322,6 +333,9 @@ pub struct Downloader {
|
|||
/// `--write-comments`. Per-channel options can override. Set at
|
||||
/// construction + on settings save.
|
||||
pub fetch_comments: bool,
|
||||
/// Global `backup.dedup_enabled`. When false, the perceptual "similar
|
||||
/// content" scan is hard-disabled in both UIs. Set at construction + save.
|
||||
pub dedup_enabled: bool,
|
||||
/// Global `[convert]` config. Drives the post-download ffmpeg pass.
|
||||
/// Per-channel options override the mode. Set at construction + save.
|
||||
pub convert_defaults: crate::config::ConvertSection,
|
||||
|
|
@ -369,6 +383,7 @@ impl Downloader {
|
|||
youtube_player_clients: String::new(),
|
||||
sponsorblock_mode: "mark".to_string(),
|
||||
fetch_comments: false,
|
||||
dedup_enabled: true,
|
||||
convert_defaults: crate::config::ConvertSection::default(),
|
||||
retry_queue: Vec::new(),
|
||||
rate_limited_backoff: false,
|
||||
|
|
@ -1029,6 +1044,7 @@ impl Downloader {
|
|||
child_pid,
|
||||
last_activity: std::time::Instant::now(),
|
||||
watchdog_killed: false,
|
||||
cancelled: false,
|
||||
rx,
|
||||
});
|
||||
}
|
||||
|
|
@ -1069,6 +1085,7 @@ impl Downloader {
|
|||
child_pid: None, // no process to watchdog
|
||||
last_activity: std::time::Instant::now(),
|
||||
watchdog_killed: false,
|
||||
cancelled: false,
|
||||
rx,
|
||||
});
|
||||
}
|
||||
|
|
@ -1374,6 +1391,7 @@ impl Downloader {
|
|||
child_pid,
|
||||
last_activity: std::time::Instant::now(),
|
||||
watchdog_killed: false,
|
||||
cancelled: false,
|
||||
rx,
|
||||
});
|
||||
}
|
||||
|
|
@ -1396,6 +1414,55 @@ impl Downloader {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a running job by index: SIGKILL its process and mark it
|
||||
/// `cancelled` so the auto-retry path skips it and the UI shows a
|
||||
/// distinct "cancelled" state rather than a misleading error class.
|
||||
/// The reader thread's `wait()` returns once the process dies and
|
||||
/// delivers `Finished(false)` normally, transitioning it to Failed.
|
||||
/// No-op for non-running jobs. Returns whether a job was cancelled.
|
||||
pub fn cancel_job(&mut self, idx: usize) -> bool {
|
||||
let Some(job) = self.jobs.get_mut(idx) else { return false };
|
||||
if job.state != JobState::Running { return false; }
|
||||
if let Some(pid) = job.child_pid {
|
||||
kill_pid(pid);
|
||||
}
|
||||
job.cancelled = true;
|
||||
job.retry_handled = true; // a user cancel must not auto-retry
|
||||
job.last_activity = std::time::Instant::now();
|
||||
job.log.push_back("⛔ cancelled by user".to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Cancel a still-queued (not-yet-started) job by index into the pending
|
||||
/// queue. Returns whether an entry was removed.
|
||||
pub fn cancel_queued(&mut self, idx: usize) -> bool {
|
||||
if idx < self.pending.len() {
|
||||
self.pending.remove(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually re-issue a finished (failed/cancelled/done) job by index,
|
||||
/// reusing the inputs captured at start time. Resets the attempt counter
|
||||
/// so the user-triggered retry gets a fresh auto-retry budget. The old
|
||||
/// job row is removed. No-op (returns false) for a running job or one
|
||||
/// with no captured `retry_spec` (e.g. live recordings, self-update).
|
||||
pub fn retry_job(&mut self, idx: usize) -> bool {
|
||||
let Some(job) = self.jobs.get(idx) else { return false };
|
||||
if job.state == JobState::Running { return false; }
|
||||
let Some(spec) = job.retry_spec.clone() else { return false };
|
||||
self.jobs.remove(idx);
|
||||
self.start_retry(spec, 0);
|
||||
true
|
||||
}
|
||||
|
||||
/// Full log buffer for a job by index (for the UI's per-job log view).
|
||||
pub fn job_log(&self, idx: usize) -> Option<Vec<String>> {
|
||||
self.jobs.get(idx).map(|j| j.log.iter().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Downloader {
|
||||
|
|
@ -1626,6 +1693,7 @@ mod tests {
|
|||
child_pid: Some(999_999),
|
||||
last_activity: std::time::Instant::now(),
|
||||
watchdog_killed: true,
|
||||
cancelled: false,
|
||||
rx,
|
||||
};
|
||||
tx.send(Msg::Finished(false)).unwrap();
|
||||
|
|
@ -1645,7 +1713,7 @@ mod tests {
|
|||
retry_spec: None, retry_count: 0, retry_handled: false,
|
||||
convert_on_finish: None, convert_handled: false,
|
||||
child_pid: Some(1), last_activity: std::time::Instant::now(),
|
||||
watchdog_killed: false, rx,
|
||||
watchdog_killed: false, cancelled: false, rx,
|
||||
};
|
||||
tx.send(Msg::Line("ERROR: Video unavailable. This video has been removed".into())).unwrap();
|
||||
tx.send(Msg::Finished(false)).unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue