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

@ -359,10 +359,12 @@ switch to **Bundled** in Settings.
**Web UI says "authentication required" but no password is set**
Sessions cleared after the password was changed. Refresh the page.
**Videos play but seeking jumps to the start**
That's the transcoding mode (`web.transcode = true`). MKV streams can't be
seeked while transcoding live; turn it off if your browser can play MKV
directly.
**Videos play but seeking is approximate**
That's usually transcoding mode (`web.transcode = true`). yt-offline can
scrub those live ffmpeg streams by re-requesting the stream at a `start=`
offset, but ffmpeg's fast seek lands on a nearby keyframe rather than an
exact byte position. Turn transcoding off if your browser can play the
original file directly and you want native browser seeking.
**Connection-reset / "Recv failure" errors on download**
The defaults already retry 30× with linear backoff. If they're persistent,

View file

@ -237,6 +237,18 @@ Probably never, or much later.
Roughly reverse-chronological. Items that closed out a roadmap line.
- **Seekable transcoded web playback**`/api/transcode/:id` accepts a
`start=` offset and the web player uses custom controls for live ffmpeg
streams, so MKV/H.264 transcode mode can scrub, resume, jump chapters,
and sync transcript highlights even though the pipe has no byte ranges.
- **Federation / multi-host** (3.5) — read-only peer libraries with
tokenized direct media URLs, so videos stream from the peer rather than
proxying through this instance.
- **Smart auto-tagging** (3.4) — suggests folder groups for unfiled
channels from platform, median duration, and upload cadence; never
moves channels until the user applies the suggestion.
- **Podcast/RSS feeds** — whole-library and per-channel feed URLs with a
stable read-only token for feed readers and media clients.
- **Transcripts in search** (3.9) — subtitle text folded into the FTS
index so search matches spoken words; FTS5 column-add migration.
- **Perceptual-hash dedup** (3.7) — `fingerprint.rs` (ffmpeg→dHash,
@ -306,14 +318,15 @@ Roughly reverse-chronological. Items that closed out a roadmap line.
- **Phase 2** is complete: integration tests (2.1), docs site (2.2),
error recovery (2.3), restore (2.4), stability hardening (2.5).
- **Phase 3** is the "surpass" work now that we're at parity. Shipped so
far: WebSocket progress (3.3), comment viewer (3.6), perceptual-hash
dedup (3.7), and full-text search + transcript tooling (3.9). Still open:
Windows/macOS binaries (3.1), Android (3.2), smart auto-tagging (3.4),
federation (3.5), plugin hooks (3.8).
far: WebSocket progress (3.3), smart auto-tagging (3.4), federation
(3.5), comment viewer (3.6), perceptual-hash dedup (3.7), and
full-text search + transcript tooling (3.9). Still open: shipped
Windows/macOS binaries (3.1; compile groundwork is done), Android
(3.2), and plugin hooks (3.8).
- **Phase 4** items might be valuable, but commit to nothing.
Items inside a phase are loosely ordered by user-visible impact, not strict
prerequisite. With the easy "surpass" wins banked, the highest-leverage
remaining moves are **3.1 (Windows/macOS binaries)** for reach and a
**RSS/podcast feed** or **smart auto-tagging (3.4)** for self-contained
features Tartube can't match.
remaining moves are **3.1 (Windows/macOS release artifacts)** for reach,
**3.8 (plugin hooks)** for extensibility, and **3.2 (Android client)** for
mobile-first consumption.

View file

@ -449,6 +449,7 @@ impl App {
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;
// Federation peers, built once from config (read-only remote libraries).
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>> = config.remotes.iter()
.map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r)))
@ -961,6 +962,10 @@ impl App {
/// result over a channel. No-op if a job is already running.
fn start_dedup(&mut self) {
if self.dedup_running { return; }
if !self.config.backup.dedup_enabled {
self.dedup_error = Some("Similar-content scan is disabled in Settings.".into());
return;
}
let mut inputs = Vec::new();
let mut by_path: HashMap<String, SimVideo> = HashMap::new();
let mut valid_paths: HashSet<String> = HashSet::new();
@ -2027,6 +2032,8 @@ impl App {
}
}
let mut remove_job: Option<usize> = None;
let mut cancel_job: Option<usize> = None;
let mut retry_job: Option<usize> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
let n = self.downloader.jobs.len();
for i in (0..n).rev() {
@ -2034,21 +2041,28 @@ impl App {
let (text, color) = match job.state {
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)),
JobState::Failed if job.cancelled => ("cancelled", egui::Color32::from_rgb(150, 150, 150)),
JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)),
};
let finished = job.state != JobState::Running;
let can_retry = finished && job.has_retry_spec();
ui.push_id(i, |ui| {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.colored_label(color, text);
ui.label(&job.label);
if finished {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if finished {
if ui.small_button("").clicked() {
remove_job = Some(i);
}
});
if can_retry && ui.small_button("↻ Retry").clicked() {
retry_job = Some(i);
}
} else if ui.small_button("⛔ Cancel").clicked() {
cancel_job = Some(i);
}
});
});
ui.label(egui::RichText::new(&job.url).small().weak());
if job.state == JobState::Running {
@ -2091,6 +2105,12 @@ impl App {
});
}
});
if let Some(i) = cancel_job {
self.downloader.cancel_job(i);
}
if let Some(i) = retry_job {
self.downloader.retry_job(i);
}
if let Some(i) = remove_job {
self.downloader.remove_job(i);
}
@ -3293,6 +3313,15 @@ impl App {
Per-channel overrides live in each channel's options.");
ui.end_row();
ui.label("Similar-content scan:");
ui.checkbox(&mut self.config.backup.dedup_enabled, "Enable perceptual dedup")
.on_hover_text(
"When on, the Maintenance \"Scan for similar content\" button \
fingerprints your videos (an ffmpeg keyframe pass) to find \
visual duplicates. Turn off on low-powered machines to skip \
that work entirely.");
ui.end_row();
ui.label("Web UI port:");
ui.add(
egui::DragValue::new(&mut self.config.web.port)
@ -3703,6 +3732,7 @@ impl App {
self.downloader.youtube_player_clients = self.config.backup.youtube_player_clients.clone();
self.downloader.sponsorblock_mode = self.config.backup.sponsorblock_mode.clone();
self.downloader.fetch_comments = self.config.backup.fetch_comments;
self.downloader.dedup_enabled = self.config.backup.dedup_enabled;
self.downloader.convert_defaults = self.config.convert.clone();
if dir_changed {
self.channels_root = new_dir.clone();

View file

@ -160,10 +160,16 @@ pub struct BackupSection {
/// live in [`crate::download_options::DownloadOptions`].
#[serde(default)]
pub fetch_comments: bool,
/// Whether the perceptual "similar content" dedup scan is available. On by
/// default; set to `false` to hard-disable fingerprinting in both UIs (the
/// scan button no-ops / the web scan endpoint returns 409). Useful on
/// low-powered machines where the ffmpeg keyframe pass is unwanted.
#[serde(default = "default_true")]
pub dedup_enabled: bool,
}
fn default_max_concurrent() -> usize { 3 }
fn default_true() -> bool { true }
pub fn default_true() -> bool { true }
fn default_sponsorblock_mode() -> String { "mark".to_string() }
/// `[player]` table — external player and browser cookie source.
@ -300,6 +306,7 @@ impl Config {
youtube_player_clients: String::new(),
sponsorblock_mode: default_sponsorblock_mode(),
fetch_comments: false,
dedup_enabled: true,
},
player: PlayerSection::default(),
ui: UiSection::default(),

View file

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

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|
// 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: j.failure_class.map(|c| c.hint()).unwrap_or(""),
)
},
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))

View file

@ -142,7 +142,10 @@
.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}
.badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171}.badge.cancelled{color:var(--muted)}.badge.queued{color:var(--muted)}
.job-meta{font-size:11px;color:var(--muted);flex-shrink:0;font-variant-numeric:tabular-nums}
.job-act{font-size:11px;padding:1px 7px;flex-shrink:0}
.job-log{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin:0 14px 8px;padding:8px;max-height:220px;overflow:auto;color:var(--muted)}
/* 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}
@ -1911,6 +1914,11 @@ async function openSettings(){
<input type="checkbox" id="cf-comments" ${cur.fetch_comments?'checked':''}>
</div>
<div class="settings-hint" style="margin-bottom:6px">Adds <code>--write-comments</code> to every download — fetches each video's comment tree into its info.json so the player's Comments tab works. Slow on popular videos. Per-channel overrides live in each channel's options.</div>
<div class="settings-row" style="margin-top:8px">
<label for="cf-dedup">Similar-content scan</label>
<input type="checkbox" id="cf-dedup" ${cur.dedup_enabled!==false?'checked':''}>
</div>
<div class="settings-hint" style="margin-bottom:6px">Enables the Maintenance &ldquo;Scan for similar content&rdquo; tool, which fingerprints your videos (an ffmpeg keyframe pass) to find visual duplicates. Turn off on low-powered machines to skip that work.</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div>
<div class="settings-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div>
@ -2154,12 +2162,13 @@ async function saveSettings(btn){
const playerClients=document.getElementById('cf-player-clients')?.value||'';
const sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark';
const fetchComments=document.getElementById('cf-comments')?.checked||false;
const dedupEnabled=document.getElementById('cf-dedup')?.checked!==false;
const convMode=document.getElementById('cf-convert-mode')?.value||'';
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
const convKeep=document.getElementById('cf-convert-keep')?.checked||false;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients,sponsorblock_mode:sponsorblock,fetch_comments:fetchComments,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep};
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients,sponsorblock_mode:sponsorblock,fetch_comments:fetchComments,dedup_enabled:dedupEnabled,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep};
if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
@ -2398,7 +2407,15 @@ function dedupIdle(err){
}
async function startDedup(btn){
if(btn)btn.disabled=true;
try{await api('/api/maintenance/dedup/scan',{method:'POST'});startDedupPoll();}
try{
const r=await(await api('/api/maintenance/dedup/scan',{method:'POST'})).json();
if(r&&r.disabled){
const a=document.getElementById('dedup-area');
if(a)a.innerHTML=`<div style="color:var(--muted);font-size:12px">Similar-content scan is disabled in Settings.</div>`;
return;
}
startDedupPoll();
}
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
}
function startDedupPoll(){
@ -2509,13 +2526,16 @@ function renderJobs(jobs,queued,maxConcurrent){
}
const fin=jobs.some(j=>j.state!=='running');
const hdr=fin?`<div style="padding:4px 14px;display:flex;justify-content:flex-end;border-bottom:1px solid var(--border)"><button onclick="clearFinishedJobs()" style="font-size:11px;padding:2px 8px">✕ Clear finished</button></div>`:'';
const queuedHtml=queued.map(q=>`<div class="job">
<span class="badge" style="color:var(--muted)">queued</span>
// Queued jobs map 1:1 to the server's pending-queue index, so the cancel
// button can DELETE /api/queued/<i>.
const queuedHtml=queued.map((q,i)=>`<div class="job">
<span class="badge queued">queued</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)">${esc(q.label)} — ${esc(q.url)}</span>
<button class="job-act" onclick="cancelQueued(${i})" title="Remove from queue"></button>
</div>`).join('');
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>`:'';
const running=j.state==='running';
// 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
@ -2524,19 +2544,71 @@ function renderJobs(jobs,queued,maxConcurrent){
? `<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">
// Live speed/ETA parsed from the yt-dlp progress line (running only);
// otherwise show the raw last log line.
const meta=running?jobMeta(j.last_line):'';
const pct=running?` ${Math.round((j.progress||0)*100)}%`:'';
// Per-row actions: cancel (running), retry (finished + retryable),
// log toggle (always), dismiss (finished).
const acts=[];
if(running)acts.push(`<button class="job-act" onclick="cancelJob(${i},this)" title="Stop this download">⛔ Cancel</button>`);
if(!running&&j.can_retry)acts.push(`<button class="job-act" onclick="retryJob(${i},this)" title="Re-run this download">↻ Retry</button>`);
acts.push(`<button class="job-act" onclick="toggleJobLog(${i},this)" title="Show full log">📄 Log</button>`);
if(!running)acts.push(`<button class="job-act" onclick="removeJob(${i})" title="Dismiss"></button>`);
return `<div class="job-row" data-job="${i}">
<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}
${running?`<progress value="${j.progress}" max="1"></progress><span class="job-meta">${esc(meta)}${pct}</span>`:`<span class="last-line" style="font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>`}
${acts.join('')}
</div>
${errHint}
</div>`;
}).join('')+queuedHtml+limitNote;
}
// Parse a yt-dlp "[download] 42.7% of 100MiB at 5MiB/s ETA 00:10" line into a
// compact "5MiB/s · ETA 00:10" meta string. Empty when the line isn't a
// recognizable download-progress line (post-processing, fragments, etc.).
function jobMeta(line){
if(!line)return '';
const sp=line.match(/at\s+([\d.]+\s*[KMGT]?i?B\/s)/i);
const eta=line.match(/ETA\s+([\d:]+)/i);
const parts=[];
if(sp)parts.push(sp[1].replace(/\s+/g,''));
if(eta)parts.push('ETA '+eta[1]);
return parts.join(' · ');
}
async function cancelJob(idx,btn){
if(btn)btn.disabled=true;
try{await api('/api/jobs/'+idx+'/cancel',{method:'POST'});await pollProgress();}
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
}
async function retryJob(idx,btn){
if(btn)btn.disabled=true;
try{await api('/api/jobs/'+idx+'/retry',{method:'POST'});await pollProgress();}
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
}
async function cancelQueued(idx){
try{await api('/api/queued/'+idx,{method:'DELETE'});await pollProgress();}
catch(e){setStatus('Error: '+e.message)}
}
// Expand/collapse the full server-side log for one job, fetched on demand.
async function toggleJobLog(idx,btn){
const row=document.querySelector(`.job-row[data-job="${idx}"]`);
if(!row)return;
const existing=row.querySelector('.job-log');
if(existing){existing.remove();if(btn)btn.classList.remove('primary');return}
if(btn)btn.classList.add('primary');
const pre=document.createElement('div');
pre.className='job-log';pre.textContent='Loading…';
row.appendChild(pre);
try{
const d=await(await api('/api/jobs/'+idx+'/log')).json();
pre.textContent=(d.lines||[]).join('\n')||'(no log output)';
pre.scrollTop=pre.scrollHeight;
}catch(e){pre.textContent='Failed to load log: '+e.message}
}
// Open the Downloads modal. If it's already open, do nothing (clicking the
// ⬇ button again would otherwise stack duplicate modals).
//