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
10
README.md
10
README.md
|
|
@ -359,10 +359,12 @@ switch to **Bundled** in Settings.
|
||||||
**Web UI says "authentication required" but no password is set**
|
**Web UI says "authentication required" but no password is set**
|
||||||
Sessions cleared after the password was changed. Refresh the page.
|
Sessions cleared after the password was changed. Refresh the page.
|
||||||
|
|
||||||
**Videos play but seeking jumps to the start**
|
**Videos play but seeking is approximate**
|
||||||
That's the transcoding mode (`web.transcode = true`). MKV streams can't be
|
That's usually transcoding mode (`web.transcode = true`). yt-offline can
|
||||||
seeked while transcoding live; turn it off if your browser can play MKV
|
scrub those live ffmpeg streams by re-requesting the stream at a `start=`
|
||||||
directly.
|
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**
|
**Connection-reset / "Recv failure" errors on download**
|
||||||
The defaults already retry 30× with linear backoff. If they're persistent,
|
The defaults already retry 30× with linear backoff. If they're persistent,
|
||||||
|
|
|
||||||
27
ROADMAP.md
27
ROADMAP.md
|
|
@ -237,6 +237,18 @@ Probably never, or much later.
|
||||||
|
|
||||||
Roughly reverse-chronological. Items that closed out a roadmap line.
|
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
|
- **Transcripts in search** (3.9) — subtitle text folded into the FTS
|
||||||
index so search matches spoken words; FTS5 column-add migration.
|
index so search matches spoken words; FTS5 column-add migration.
|
||||||
- **Perceptual-hash dedup** (3.7) — `fingerprint.rs` (ffmpeg→dHash,
|
- **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),
|
- **Phase 2** is complete: integration tests (2.1), docs site (2.2),
|
||||||
error recovery (2.3), restore (2.4), stability hardening (2.5).
|
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
|
- **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
|
far: WebSocket progress (3.3), smart auto-tagging (3.4), federation
|
||||||
dedup (3.7), and full-text search + transcript tooling (3.9). Still open:
|
(3.5), comment viewer (3.6), perceptual-hash dedup (3.7), and
|
||||||
Windows/macOS binaries (3.1), Android (3.2), smart auto-tagging (3.4),
|
full-text search + transcript tooling (3.9). Still open: shipped
|
||||||
federation (3.5), plugin hooks (3.8).
|
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.
|
- **Phase 4** items might be valuable, but commit to nothing.
|
||||||
|
|
||||||
Items inside a phase are loosely ordered by user-visible impact, not strict
|
Items inside a phase are loosely ordered by user-visible impact, not strict
|
||||||
prerequisite. With the easy "surpass" wins banked, the highest-leverage
|
prerequisite. With the easy "surpass" wins banked, the highest-leverage
|
||||||
remaining moves are **3.1 (Windows/macOS binaries)** for reach and a
|
remaining moves are **3.1 (Windows/macOS release artifacts)** for reach,
|
||||||
**RSS/podcast feed** or **smart auto-tagging (3.4)** for self-contained
|
**3.8 (plugin hooks)** for extensibility, and **3.2 (Android client)** for
|
||||||
features Tartube can't match.
|
mobile-first consumption.
|
||||||
|
|
|
||||||
38
src/app.rs
38
src/app.rs
|
|
@ -449,6 +449,7 @@ impl App {
|
||||||
downloader.youtube_player_clients = config.backup.youtube_player_clients.clone();
|
downloader.youtube_player_clients = config.backup.youtube_player_clients.clone();
|
||||||
downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
|
downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
|
||||||
downloader.fetch_comments = config.backup.fetch_comments;
|
downloader.fetch_comments = config.backup.fetch_comments;
|
||||||
|
downloader.dedup_enabled = config.backup.dedup_enabled;
|
||||||
// Federation peers, built once from config (read-only remote libraries).
|
// Federation peers, built once from config (read-only remote libraries).
|
||||||
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>> = config.remotes.iter()
|
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>> = config.remotes.iter()
|
||||||
.map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r)))
|
.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.
|
/// result over a channel. No-op if a job is already running.
|
||||||
fn start_dedup(&mut self) {
|
fn start_dedup(&mut self) {
|
||||||
if self.dedup_running { return; }
|
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 inputs = Vec::new();
|
||||||
let mut by_path: HashMap<String, SimVideo> = HashMap::new();
|
let mut by_path: HashMap<String, SimVideo> = HashMap::new();
|
||||||
let mut valid_paths: HashSet<String> = HashSet::new();
|
let mut valid_paths: HashSet<String> = HashSet::new();
|
||||||
|
|
@ -2027,6 +2032,8 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut remove_job: Option<usize> = None;
|
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| {
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
let n = self.downloader.jobs.len();
|
let n = self.downloader.jobs.len();
|
||||||
for i in (0..n).rev() {
|
for i in (0..n).rev() {
|
||||||
|
|
@ -2034,21 +2041,28 @@ impl App {
|
||||||
let (text, color) = match job.state {
|
let (text, color) = match job.state {
|
||||||
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
|
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
|
||||||
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)),
|
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)),
|
JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)),
|
||||||
};
|
};
|
||||||
let finished = job.state != JobState::Running;
|
let finished = job.state != JobState::Running;
|
||||||
|
let can_retry = finished && job.has_retry_spec();
|
||||||
ui.push_id(i, |ui| {
|
ui.push_id(i, |ui| {
|
||||||
ui.group(|ui| {
|
ui.group(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.colored_label(color, text);
|
ui.colored_label(color, text);
|
||||||
ui.label(&job.label);
|
ui.label(&job.label);
|
||||||
if finished {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
if finished {
|
||||||
if ui.small_button("✕").clicked() {
|
if ui.small_button("✕").clicked() {
|
||||||
remove_job = Some(i);
|
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());
|
ui.label(egui::RichText::new(&job.url).small().weak());
|
||||||
if job.state == JobState::Running {
|
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 {
|
if let Some(i) = remove_job {
|
||||||
self.downloader.remove_job(i);
|
self.downloader.remove_job(i);
|
||||||
}
|
}
|
||||||
|
|
@ -3293,6 +3313,15 @@ impl App {
|
||||||
Per-channel overrides live in each channel's options.");
|
Per-channel overrides live in each channel's options.");
|
||||||
ui.end_row();
|
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.label("Web UI port:");
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::DragValue::new(&mut self.config.web.port)
|
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.youtube_player_clients = self.config.backup.youtube_player_clients.clone();
|
||||||
self.downloader.sponsorblock_mode = self.config.backup.sponsorblock_mode.clone();
|
self.downloader.sponsorblock_mode = self.config.backup.sponsorblock_mode.clone();
|
||||||
self.downloader.fetch_comments = self.config.backup.fetch_comments;
|
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();
|
self.downloader.convert_defaults = self.config.convert.clone();
|
||||||
if dir_changed {
|
if dir_changed {
|
||||||
self.channels_root = new_dir.clone();
|
self.channels_root = new_dir.clone();
|
||||||
|
|
|
||||||
|
|
@ -160,10 +160,16 @@ pub struct BackupSection {
|
||||||
/// live in [`crate::download_options::DownloadOptions`].
|
/// live in [`crate::download_options::DownloadOptions`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub fetch_comments: bool,
|
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_max_concurrent() -> usize { 3 }
|
||||||
fn default_true() -> bool { true }
|
pub fn default_true() -> bool { true }
|
||||||
fn default_sponsorblock_mode() -> String { "mark".to_string() }
|
fn default_sponsorblock_mode() -> String { "mark".to_string() }
|
||||||
|
|
||||||
/// `[player]` table — external player and browser cookie source.
|
/// `[player]` table — external player and browser cookie source.
|
||||||
|
|
@ -300,6 +306,7 @@ impl Config {
|
||||||
youtube_player_clients: String::new(),
|
youtube_player_clients: String::new(),
|
||||||
sponsorblock_mode: default_sponsorblock_mode(),
|
sponsorblock_mode: default_sponsorblock_mode(),
|
||||||
fetch_comments: false,
|
fetch_comments: false,
|
||||||
|
dedup_enabled: true,
|
||||||
},
|
},
|
||||||
player: PlayerSection::default(),
|
player: PlayerSection::default(),
|
||||||
ui: UiSection::default(),
|
ui: UiSection::default(),
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ pub struct Job {
|
||||||
/// On the resulting failure we force a retryable class so auto-retry
|
/// On the resulting failure we force a retryable class so auto-retry
|
||||||
/// re-queues it — a hang is transient, worth one more try.
|
/// re-queues it — a hang is transient, worth one more try.
|
||||||
watchdog_killed: bool,
|
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>,
|
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);
|
const HANG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
impl Job {
|
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) {
|
fn drain(&mut self) {
|
||||||
while let Ok(msg) = self.rx.try_recv() {
|
while let Ok(msg) = self.rx.try_recv() {
|
||||||
match msg {
|
match msg {
|
||||||
|
|
@ -322,6 +333,9 @@ pub struct Downloader {
|
||||||
/// `--write-comments`. Per-channel options can override. Set at
|
/// `--write-comments`. Per-channel options can override. Set at
|
||||||
/// construction + on settings save.
|
/// construction + on settings save.
|
||||||
pub fetch_comments: bool,
|
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.
|
/// Global `[convert]` config. Drives the post-download ffmpeg pass.
|
||||||
/// Per-channel options override the mode. Set at construction + save.
|
/// Per-channel options override the mode. Set at construction + save.
|
||||||
pub convert_defaults: crate::config::ConvertSection,
|
pub convert_defaults: crate::config::ConvertSection,
|
||||||
|
|
@ -369,6 +383,7 @@ impl Downloader {
|
||||||
youtube_player_clients: String::new(),
|
youtube_player_clients: String::new(),
|
||||||
sponsorblock_mode: "mark".to_string(),
|
sponsorblock_mode: "mark".to_string(),
|
||||||
fetch_comments: false,
|
fetch_comments: false,
|
||||||
|
dedup_enabled: true,
|
||||||
convert_defaults: crate::config::ConvertSection::default(),
|
convert_defaults: crate::config::ConvertSection::default(),
|
||||||
retry_queue: Vec::new(),
|
retry_queue: Vec::new(),
|
||||||
rate_limited_backoff: false,
|
rate_limited_backoff: false,
|
||||||
|
|
@ -1029,6 +1044,7 @@ impl Downloader {
|
||||||
child_pid,
|
child_pid,
|
||||||
last_activity: std::time::Instant::now(),
|
last_activity: std::time::Instant::now(),
|
||||||
watchdog_killed: false,
|
watchdog_killed: false,
|
||||||
|
cancelled: false,
|
||||||
rx,
|
rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1069,6 +1085,7 @@ impl Downloader {
|
||||||
child_pid: None, // no process to watchdog
|
child_pid: None, // no process to watchdog
|
||||||
last_activity: std::time::Instant::now(),
|
last_activity: std::time::Instant::now(),
|
||||||
watchdog_killed: false,
|
watchdog_killed: false,
|
||||||
|
cancelled: false,
|
||||||
rx,
|
rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1374,6 +1391,7 @@ impl Downloader {
|
||||||
child_pid,
|
child_pid,
|
||||||
last_activity: std::time::Instant::now(),
|
last_activity: std::time::Instant::now(),
|
||||||
watchdog_killed: false,
|
watchdog_killed: false,
|
||||||
|
cancelled: false,
|
||||||
rx,
|
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 {
|
impl Drop for Downloader {
|
||||||
|
|
@ -1626,6 +1693,7 @@ mod tests {
|
||||||
child_pid: Some(999_999),
|
child_pid: Some(999_999),
|
||||||
last_activity: std::time::Instant::now(),
|
last_activity: std::time::Instant::now(),
|
||||||
watchdog_killed: true,
|
watchdog_killed: true,
|
||||||
|
cancelled: false,
|
||||||
rx,
|
rx,
|
||||||
};
|
};
|
||||||
tx.send(Msg::Finished(false)).unwrap();
|
tx.send(Msg::Finished(false)).unwrap();
|
||||||
|
|
@ -1645,7 +1713,7 @@ mod tests {
|
||||||
retry_spec: None, retry_count: 0, retry_handled: false,
|
retry_spec: None, retry_count: 0, retry_handled: false,
|
||||||
convert_on_finish: None, convert_handled: false,
|
convert_on_finish: None, convert_handled: false,
|
||||||
child_pid: Some(1), last_activity: std::time::Instant::now(),
|
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::Line("ERROR: Video unavailable. This video has been removed".into())).unwrap();
|
||||||
tx.send(Msg::Finished(false)).unwrap();
|
tx.send(Msg::Finished(false)).unwrap();
|
||||||
|
|
|
||||||
92
src/web.rs
92
src/web.rs
|
|
@ -58,10 +58,13 @@ use crate::maintenance;
|
||||||
pub struct JobSnapshot {
|
pub struct JobSnapshot {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// One of `"running"`, `"done"`, or `"failed"`.
|
/// One of `"running"`, `"done"`, `"failed"`, or `"cancelled"`.
|
||||||
pub state: &'static str,
|
pub state: &'static str,
|
||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
pub last_line: String,
|
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
|
/// Classification of the failure, if `state == "failed"`. One of
|
||||||
/// `rate-limited`, `members-only`, `geo-blocked`, `not-found`,
|
/// `rate-limited`, `members-only`, `geo-blocked`, `not-found`,
|
||||||
/// `codec-missing`, `disk-full`, `network-error`, `bad-cookies`, `other`,
|
/// `codec-missing`, `disk-full`, `network-error`, `bad-cookies`, `other`,
|
||||||
|
|
@ -205,16 +208,25 @@ impl WebState {
|
||||||
state: match j.state {
|
state: match j.state {
|
||||||
JobState::Running => "running",
|
JobState::Running => "running",
|
||||||
JobState::Done => "done",
|
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",
|
JobState::Failed => "failed",
|
||||||
},
|
},
|
||||||
progress: j.progress,
|
progress: j.progress,
|
||||||
last_line: j.log.back().cloned().unwrap_or_default(),
|
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
|
// Skip `Other` so the badge doesn't get a useless generic
|
||||||
// label — the raw log line is still shown for that case.
|
// label — the raw log line is still shown for that case. A
|
||||||
error_class: j.failure_class.filter(|c|
|
// cancelled job has no meaningful error class either.
|
||||||
*c != crate::error_class::ErrorClass::Other
|
error_class: if j.cancelled { None } else {
|
||||||
),
|
j.failure_class.filter(|c|
|
||||||
error_hint: j.failure_class.map(|c| c.hint()).unwrap_or(""),
|
*c != crate::error_class::ErrorClass::Other
|
||||||
|
)
|
||||||
|
},
|
||||||
|
error_hint: if j.cancelled { "" } else {
|
||||||
|
j.failure_class.map(|c| c.hint()).unwrap_or("")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -424,6 +436,9 @@ struct SettingsPayload {
|
||||||
/// live in each channel's DownloadOptions, not here.
|
/// live in each channel's DownloadOptions, not here.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
fetch_comments: bool,
|
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.
|
/// Global [convert] settings, round-tripped on GET + POST.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
convert_mode: String,
|
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 player_clients_out = cfg.backup.youtube_player_clients.clone();
|
||||||
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
||||||
let fetch_comments_out = cfg.backup.fetch_comments;
|
let fetch_comments_out = cfg.backup.fetch_comments;
|
||||||
|
let dedup_enabled_out = cfg.backup.dedup_enabled;
|
||||||
let convert = cfg.convert.clone();
|
let convert = cfg.convert.clone();
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
|
|
@ -1567,6 +1583,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
youtube_player_clients: player_clients_out.clone(),
|
youtube_player_clients: player_clients_out.clone(),
|
||||||
sponsorblock_mode: sponsorblock_out.clone(),
|
sponsorblock_mode: sponsorblock_out.clone(),
|
||||||
fetch_comments: fetch_comments_out,
|
fetch_comments: fetch_comments_out,
|
||||||
|
dedup_enabled: dedup_enabled_out,
|
||||||
convert_mode: convert.mode.clone(),
|
convert_mode: convert.mode.clone(),
|
||||||
convert_crf: convert.crf,
|
convert_crf: convert.crf,
|
||||||
convert_preset: convert.preset.clone(),
|
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.youtube_player_clients = body.youtube_player_clients.trim().to_string();
|
||||||
cfg.backup.sponsorblock_mode = body.sponsorblock_mode.trim().to_string();
|
cfg.backup.sponsorblock_mode = body.sponsorblock_mode.trim().to_string();
|
||||||
cfg.backup.fetch_comments = body.fetch_comments;
|
cfg.backup.fetch_comments = body.fetch_comments;
|
||||||
|
cfg.backup.dedup_enabled = body.dedup_enabled;
|
||||||
// Global format-conversion defaults.
|
// Global format-conversion defaults.
|
||||||
cfg.convert.mode = body.convert_mode.trim().to_string();
|
cfg.convert.mode = body.convert_mode.trim().to_string();
|
||||||
cfg.convert.crf = body.convert_crf;
|
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 player_clients_out = cfg.backup.youtube_player_clients.clone();
|
||||||
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
||||||
let fetch_comments_out = cfg.backup.fetch_comments;
|
let fetch_comments_out = cfg.backup.fetch_comments;
|
||||||
|
let dedup_enabled_out = cfg.backup.dedup_enabled;
|
||||||
let convert = cfg.convert.clone();
|
let convert = cfg.convert.clone();
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
|
|
@ -1652,6 +1671,7 @@ async fn post_settings(
|
||||||
dl.youtube_player_clients = player_clients_out.clone();
|
dl.youtube_player_clients = player_clients_out.clone();
|
||||||
dl.sponsorblock_mode = sponsorblock_out.clone();
|
dl.sponsorblock_mode = sponsorblock_out.clone();
|
||||||
dl.fetch_comments = fetch_comments_out;
|
dl.fetch_comments = fetch_comments_out;
|
||||||
|
dl.dedup_enabled = dedup_enabled_out;
|
||||||
dl.convert_defaults = convert.clone();
|
dl.convert_defaults = convert.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1710,6 +1730,7 @@ async fn post_settings(
|
||||||
youtube_player_clients: player_clients_out.clone(),
|
youtube_player_clients: player_clients_out.clone(),
|
||||||
sponsorblock_mode: sponsorblock_out.clone(),
|
sponsorblock_mode: sponsorblock_out.clone(),
|
||||||
fetch_comments: fetch_comments_out,
|
fetch_comments: fetch_comments_out,
|
||||||
|
dedup_enabled: dedup_enabled_out,
|
||||||
convert_mode: convert.mode.clone(),
|
convert_mode: convert.mode.clone(),
|
||||||
convert_crf: convert.crf,
|
convert_crf: convert.crf,
|
||||||
convert_preset: convert.preset.clone(),
|
convert_preset: convert.preset.clone(),
|
||||||
|
|
@ -1950,6 +1971,54 @@ async fn post_remove_job(
|
||||||
(StatusCode::OK, "removed")
|
(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(
|
async fn get_chapters(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Path(video_id): Path<String>,
|
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;
|
/// (cached by mtime), then groups by visual similarity. Returns immediately;
|
||||||
/// poll `/api/maintenance/dedup/status` for progress + results.
|
/// poll `/api/maintenance/dedup/status` for progress + results.
|
||||||
async fn post_dedup_scan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
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) {
|
if state.dedup.running.swap(true, Ordering::SeqCst) {
|
||||||
return Json(serde_json::json!({ "started": false, "running": true }));
|
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.youtube_player_clients = config.backup.youtube_player_clients.clone();
|
||||||
downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
|
downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
|
||||||
downloader.fetch_comments = config.backup.fetch_comments;
|
downloader.fetch_comments = config.backup.fetch_comments;
|
||||||
|
downloader.dedup_enabled = config.backup.dedup_enabled;
|
||||||
downloader.convert_defaults = config.convert.clone();
|
downloader.convert_defaults = config.convert.clone();
|
||||||
let music_root = downloader.music_root();
|
let music_root = downloader.music_root();
|
||||||
let _ = std::fs::create_dir_all(&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/rescan", post(post_rescan))
|
||||||
.route("/api/jobs/clear", post(post_clear_jobs))
|
.route("/api/jobs/clear", post(post_clear_jobs))
|
||||||
.route("/api/jobs/:idx", axum::routing::delete(post_remove_job))
|
.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/description/:id", get(get_description))
|
||||||
.route("/api/chapters/:id", get(get_chapters))
|
.route("/api/chapters/:id", get(get_chapters))
|
||||||
.route("/api/comments/:id", get(get_comments))
|
.route("/api/comments/:id", get(get_comments))
|
||||||
|
|
|
||||||
|
|
@ -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{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}
|
.job-row .job{border-bottom:none}
|
||||||
.badge{font-weight:700;min-width:48px;flex-shrink:0}
|
.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
|
/* Error-class badge sits next to the state badge. Subtle so the actual
|
||||||
hint below carries the user's attention. */
|
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}
|
.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':''}>
|
<input type="checkbox" id="cf-comments" ${cur.fetch_comments?'checked':''}>
|
||||||
</div>
|
</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-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 “Scan for similar content” 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">
|
<hr style="border-color:var(--border);margin:12px 0">
|
||||||
<div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div>
|
<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>
|
<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 playerClients=document.getElementById('cf-player-clients')?.value||'';
|
||||||
const sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark';
|
const sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark';
|
||||||
const fetchComments=document.getElementById('cf-comments')?.checked||false;
|
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 convMode=document.getElementById('cf-convert-mode')?.value||'';
|
||||||
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
|
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
|
||||||
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
|
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
|
||||||
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
|
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
|
||||||
const convKeep=document.getElementById('cf-convert-keep')?.checked||false;
|
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(bindMode)payload.bind_mode=bindMode;
|
||||||
if(plexPath!==undefined)payload.plex_library_path=plexPath;
|
if(plexPath!==undefined)payload.plex_library_path=plexPath;
|
||||||
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
|
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
|
||||||
|
|
@ -2398,7 +2407,15 @@ function dedupIdle(err){
|
||||||
}
|
}
|
||||||
async function startDedup(btn){
|
async function startDedup(btn){
|
||||||
if(btn)btn.disabled=true;
|
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}
|
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||||||
}
|
}
|
||||||
function startDedupPoll(){
|
function startDedupPoll(){
|
||||||
|
|
@ -2509,13 +2526,16 @@ function renderJobs(jobs,queued,maxConcurrent){
|
||||||
}
|
}
|
||||||
const fin=jobs.some(j=>j.state!=='running');
|
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 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">
|
// Queued jobs map 1:1 to the server's pending-queue index, so the cancel
|
||||||
<span class="badge" style="color:var(--muted)">queued</span>
|
// 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>
|
<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('');
|
</div>`).join('');
|
||||||
const limitNote=maxConcurrent>0?`<div style="font-size:11px;color:var(--muted);padding:6px 14px">max ${maxConcurrent} concurrent</div>`:'';
|
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)=>{
|
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
|
// Failed-job hint: when the classifier matched a known pattern, show
|
||||||
// the suggested action below the row in a subtle box. Unclassified
|
// the suggested action below the row in a subtle box. Unclassified
|
||||||
// failures (error_class === undefined) fall back to just the raw
|
// 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>` : '';
|
? `<span class="err-class" title="${esc(j.error_hint||'')}">${esc(j.error_class)}</span>` : '';
|
||||||
const errHint=j.state==='failed'&&j.error_hint
|
const errHint=j.state==='failed'&&j.error_hint
|
||||||
? `<div class="job-hint">${esc(j.error_hint)}</div>` : '';
|
? `<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">
|
<div class="job">
|
||||||
<span class="badge ${j.state}">${j.state}</span>
|
<span class="badge ${j.state}">${j.state}</span>
|
||||||
${errBadge}
|
${errBadge}
|
||||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
<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>`:''}
|
${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>`}
|
||||||
<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>
|
${acts.join('')}
|
||||||
${dismiss}
|
|
||||||
</div>
|
</div>
|
||||||
${errHint}
|
${errHint}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')+queuedHtml+limitNote;
|
}).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
|
// Open the Downloads modal. If it's already open, do nothing (clicking the
|
||||||
// ⬇ button again would otherwise stack duplicate modals).
|
// ⬇ button again would otherwise stack duplicate modals).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue