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
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.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| {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
92
src/web.rs
92
src/web.rs
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 “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">
|
||||
<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).
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue