diff --git a/README.md b/README.md index 6017537..9db77b4 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/ROADMAP.md b/ROADMAP.md index 7ef3f41..4b0c0f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 3141545..cc5e1b3 100644 --- a/src/app.rs +++ b/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> = 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 = HashMap::new(); let mut valid_paths: HashSet = HashSet::new(); @@ -2027,6 +2032,8 @@ impl App { } } let mut remove_job: Option = None; + let mut cancel_job: Option = None; + let mut retry_job: Option = 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(); diff --git a/src/config.rs b/src/config.rs index 4bb67aa..989536a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(), diff --git a/src/downloader.rs b/src/downloader.rs index 5bd598a..c8c851c 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -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, } @@ -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> { + 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(); diff --git a/src/web.rs b/src/web.rs index b2298fd..c438655 100644 --- a/src/web.rs +++ b/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>) -> 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>) -> 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>, + Path(idx): Path, +) -> 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>, + Path(idx): Path, +) -> 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>, + Path(idx): Path, +) -> 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>, + Path(idx): Path, +) -> 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>, Path(video_id): Path, @@ -2368,6 +2437,12 @@ async fn get_feed_info(State(state): State>) -> 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>) -> 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)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 27e8769..a92156d 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -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(){
Adds --write-comments 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.
+
+ + +
+
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.

Subtitles (global defaults)
Applied to every download. Individual channels can override these in their Channel options dialog.
@@ -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=`
Similar-content scan is disabled in Settings.
`; + 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?`
`:''; - const queuedHtml=queued.map(q=>`
- queued + // Queued jobs map 1:1 to the server's pending-queue index, so the cancel + // button can DELETE /api/queued/. + const queuedHtml=queued.map((q,i)=>`
+ queued ${esc(q.label)} — ${esc(q.url)} +
`).join(''); const limitNote=maxConcurrent>0?`
max ${maxConcurrent} concurrent
`:''; body.innerHTML=hdr+jobs.map((j,i)=>{ - const dismiss=j.state!=='running'?``:''; + 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){ ? `${esc(j.error_class)}` : ''; const errHint=j.state==='failed'&&j.error_hint ? `
${esc(j.error_hint)}
` : ''; - return `
+ // 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(``); + if(!running&&j.can_retry)acts.push(``); + acts.push(``); + if(!running)acts.push(``); + return `
${j.state} ${errBadge} ${esc(j.label)} — ${esc(j.url)} - ${j.state==='running'?``:''} - ${esc(j.last_line)} - ${dismiss} + ${running?`${esc(meta)}${pct}`:`${esc(j.last_line)}`} + ${acts.join('')}
${errHint}
`; }).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). //