From aed577ea2fe705f8b348e380defdb514f5a0971d Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 10 Jun 2026 01:43:50 -0700 Subject: [PATCH] web/desktop: grid + subtitle fixes, comment toggle, faster startup Several related UI and startup fixes from one session: - Web grid: channel and music views rendered in a single column because their content was wrapped in one nested div that became a single cell of the outer CSS grid. Span the wrappers (and .empty) across all columns with grid-column:1/-1. - Subtitles: route every track through /api/sub-vtt and strip per-cue position/alignment settings (align:start position:0% etc.) from both SRT and VTT, so auto-generated captions render centered instead of left- aligned. New strip_cue_settings/normalize_vtt + sub_vtt_url helpers. - Comment downloads: add a global backup.fetch_comments toggle (the per-channel option already existed; it's now a tri-state override that defers to the global). Full five-touchpoint wiring across config, download_options, the downloader's apply_comments resolver, both UIs' global + per-channel controls, and downloader seeding. - Desktop startup: run the initial library scan + search-index sync on a background thread (mirrors the web server's deferred bind) so the window appears immediately instead of blocking on a cold-cache scan; update() swaps the library in when it lands. - Perceptual dedup: cap worker threads via fingerprint::default_workers() (~half the cores, always leaving at least one free) instead of using every core, so the hashing pass doesn't starve the UI. Co-Authored-By: Claude Opus 4.8 --- src/app.rs | 99 ++++++++++++++++++++++++++++--------- src/config.rs | 8 +++ src/download_options.rs | 18 ++++--- src/downloader.rs | 20 ++++++++ src/fingerprint.rs | 23 +++++++++ src/web.rs | 105 +++++++++++++++++++++++++++++++++++----- src/web_ui/index.html | 22 ++++++--- 7 files changed, 245 insertions(+), 50 deletions(-) diff --git a/src/app.rs b/src/app.rs index b5439fa..eda08e0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -116,6 +116,11 @@ pub struct App { /// non-YouTube content is included. library_root: PathBuf, library: Vec, + /// Receiver for the initial library scan, which runs on a background + /// thread so the window appears immediately instead of blocking on a + /// cold-cache scan of a large library. `None` once the scan has landed + /// (or was never deferred). Polled in `update()`. + library_load_rx: Option>>, sidebar_view: SidebarView, selected_video: Option, /// Edit buffer for the selected video's note, plus the id it belongs @@ -256,7 +261,9 @@ pub struct App { struct ChannelOptionsForm { quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p audio_only: bool, - fetch_comments: bool, + // Per-channel comment-fetch override: 0=Default(global), 1=On, 2=Off. + // Maps to Option on save (same tri-state as the subtitle indices). + comments_idx: usize, skip_auth_check: bool, limit_rate_kb: String, min_filesize_mb: String, @@ -369,23 +376,40 @@ impl App { let db_path = channels_root.join("yt-offline.db"); let db = Database::open(&db_path) .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); - let mut library = library::scan_channels_with_cache(&channels_root, Some(&db)); - let status = format!( - "{} channels, {} videos", - library.len(), - library.iter().map(|c| c.total_videos()).sum::() - ); - // Hydrate per-channel download options + folder assignments from - // SQLite onto the scanned library before publishing it to the UI. - if let Ok(map) = db.get_all_channel_options() { - library::apply_channel_options(&mut library, &map); - } - if let Ok(folder_map) = db.get_all_channel_assignments() { - library::apply_channel_folders(&mut library, &folder_map); - } - if let Err(e) = db.sync_search_index(&library::build_search_entries(&library)) { - eprintln!("search index sync failed: {e}"); + // Defer the (potentially multi-minute, disk-bound) library scan + + // search-index sync to a background thread so the window appears + // immediately instead of blocking on a cold-cache scan of a large + // library. The UI renders an empty library until the scan lands and + // update() swaps it in. The folder/watched/flag reads below are cheap + // DB lookups that don't depend on the scan, so they stay synchronous. + let (library_load_tx, library_load_rx) = + std::sync::mpsc::channel::>(); + let status = "Scanning library…".to_string(); + { + let db = db.clone(); + let channels_root = channels_root.clone(); + let ctx = cc.egui_ctx.clone(); + std::thread::Builder::new() + .name("yt-offline-libscan".into()) + .spawn(move || { + let mut library = library::scan_channels_with_cache(&channels_root, Some(&db)); + // Hydrate per-channel download options + folder assignments + // from SQLite onto the scanned library before publishing it. + if let Ok(map) = db.get_all_channel_options() { + library::apply_channel_options(&mut library, &map); + } + if let Ok(folder_map) = db.get_all_channel_assignments() { + library::apply_channel_folders(&mut library, &folder_map); + } + if let Err(e) = db.sync_search_index(&library::build_search_entries(&library)) { + eprintln!("search index sync failed: {e}"); + } + let _ = library_load_tx.send(library); + ctx.request_repaint(); // wake update() to swap the result in + }) + .ok(); } + let library: Vec = Vec::new(); let folders = db.list_folders().unwrap_or_default(); let watched = db.get_watched().unwrap_or_default(); let flags = db.get_video_flags().unwrap_or_default(); @@ -408,6 +432,7 @@ impl App { downloader.subtitle_defaults = config.subtitles.clone(); 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.convert_defaults = config.convert.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); @@ -473,6 +498,7 @@ impl App { channels_root: channels_root.clone(), library_root, library, + library_load_rx: Some(library_load_rx), sidebar_view: SidebarView::All, selected_video: None, note_buffer: String::new(), @@ -575,7 +601,7 @@ impl App { ChannelOptionsForm { quality_idx, audio_only: opts.audio_only, - fetch_comments: opts.fetch_comments, + comments_idx: tri_to_idx(opts.fetch_comments), skip_auth_check: opts.skip_auth_check, limit_rate_kb: num(opts.limit_rate_kb), min_filesize_mb: num(opts.min_filesize_mb), @@ -613,7 +639,7 @@ impl App { crate::download_options::DownloadOptions { quality, audio_only: f.audio_only, - fetch_comments: f.fetch_comments, + fetch_comments: idx_to_tri(f.comments_idx), skip_auth_check: f.skip_auth_check, limit_rate_kb: parse_num(&f.limit_rate_kb), min_filesize_mb: parse_num(&f.min_filesize_mb), @@ -946,7 +972,7 @@ impl App { let progress = self.dedup_progress.clone(); let (tx, rx) = std::sync::mpsc::channel(); self.dedup_rx = Some(rx); - let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let workers = crate::fingerprint::default_workers(); std::thread::spawn(move || { let res = crate::fingerprint::rebuild_and_group( &db, inputs, &valid_paths, workers, &progress.0, &progress.1, @@ -2038,6 +2064,19 @@ impl App { fn maintenance_screen(&mut self, ctx: &egui::Context) { let report = self.health_report.clone().unwrap_or_default(); + // Swap in the library once the deferred startup scan finishes. + let loaded_library = self.library_load_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some(lib) = loaded_library { + self.library = lib; + self.status = format!( + "{} channels, {} videos", + self.library.len(), + self.library.iter().map(|c| c.total_videos()).sum::() + ); + self.library_load_rx = None; + ctx.request_repaint(); + } + // Drain the background dedup result if it's ready. let mut dedup_done = None; if let Some(rx) = &self.dedup_rx { @@ -2387,8 +2426,14 @@ impl App { ui.end_row(); ui.label("Fetch comments"); - ui.checkbox(&mut form.fetch_comments, "Embed --write-comments into info.json") - .on_hover_text("Slow on popular videos. Once captured, comments are browsable from the player modal in the web UI."); + egui::ComboBox::from_id_salt("ch_comments") + .selected_text(match form.comments_idx { 1 => "On", 2 => "Off", _ => "Default (global)" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut form.comments_idx, 0, "Default (global)"); + ui.selectable_value(&mut form.comments_idx, 1, "On"); + ui.selectable_value(&mut form.comments_idx, 2, "Off"); + }) + .response.on_hover_text("Default = use the global Fetch comments setting. Adds --write-comments — slow on popular videos. Once captured, comments are browsable from the player modal in the web UI."); ui.end_row(); ui.label("Skip auth check"); @@ -3020,6 +3065,15 @@ impl App { overrides live in each channel's options."); ui.end_row(); + ui.label("Fetch comments:"); + ui.checkbox(&mut self.config.backup.fetch_comments, "Download comments (--write-comments)") + .on_hover_text( + "Fetch each video's comment tree into its info.json so the \ + web player's Comments tab is populated. Slow on popular \ + videos (yt-dlp paginates through thousands of replies). \ + Per-channel overrides live in each channel's options."); + ui.end_row(); + ui.label("Web UI port:"); ui.add( egui::DragValue::new(&mut self.config.web.port) @@ -3429,6 +3483,7 @@ impl App { self.downloader.subtitle_defaults = self.config.subtitles.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.fetch_comments = self.config.backup.fetch_comments; 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 7313c3b..f17f0da 100644 --- a/src/config.rs +++ b/src/config.rs @@ -134,6 +134,13 @@ pub struct BackupSection { /// overrides live in [`crate::download_options::DownloadOptions`]. #[serde(default = "default_sponsorblock_mode")] pub sponsorblock_mode: String, + /// Download the comment tree into each video's info.json sidecar via + /// yt-dlp `--write-comments`. Off by default — comment download is slow + /// (yt-dlp paginates through thousands of replies on popular videos). + /// When on, the player's Comments tab is populated. Per-channel overrides + /// live in [`crate::download_options::DownloadOptions`]. + #[serde(default)] + pub fetch_comments: bool, } fn default_max_concurrent() -> usize { 3 } @@ -273,6 +280,7 @@ impl Config { use_pot_provider: false, youtube_player_clients: String::new(), sponsorblock_mode: default_sponsorblock_mode(), + fetch_comments: false, }, player: PlayerSection::default(), ui: UiSection::default(), diff --git a/src/download_options.rs b/src/download_options.rs index 636c5c3..ec2d2be 100644 --- a/src/download_options.rs +++ b/src/download_options.rs @@ -112,12 +112,14 @@ pub struct DownloadOptions { #[serde(default)] pub extra_args: Vec, - /// Embed video comments into the info.json sidecar (yt-dlp - /// `--write-comments`). Off by default because comment download is slow - /// — yt-dlp paginates through thousands of replies for popular videos. - /// When on, the web UI's player modal exposes a Comments tab. + /// Per-channel override for fetching video comments into the info.json + /// sidecar (yt-dlp `--write-comments`). `None` defers to the global + /// `backup.fetch_comments`; `Some(true)`/`Some(false)` forces it on/off + /// for this channel. Resolved in the downloader's comment resolver, which + /// merges this with the global default. When on, the player's Comments + /// tab is populated (comment download is slow on popular videos). #[serde(default)] - pub fetch_comments: bool, + pub fetch_comments: Option, /// Skip yt-dlp's channel-tab authentication sanity check by passing /// `--extractor-args youtubetab:skip=authcheck`. @@ -179,9 +181,9 @@ impl DownloadOptions { cmd.arg(arg); } } - if self.fetch_comments { - cmd.arg("--write-comments"); - } + // Comment fetching (--write-comments) is emitted by the downloader's + // comment resolver, which merges this per-channel override with the + // global backup.fetch_comments default. apply() handles the rest. if self.skip_auth_check { // Its own --extractor-args flag; the youtubetab: namespace is // distinct from the POT provider's youtubepot-bgutilhttp: one, diff --git a/src/downloader.rs b/src/downloader.rs index 88d9934..5bd598a 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -18,6 +18,7 @@ //! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV | //! | `--xattrs` | Store metadata in filesystem extended attributes | //! | `--sponsorblock-mark/-remove all` | SponsorBlock handling, per `sponsorblock_mode` (off/mark/remove; see [`Self::apply_sponsorblock`]) | +//! | `--write-comments` | Fetch the comment tree, per `fetch_comments` global + per-channel override (see [`Self::apply_comments`]) | //! | `--impersonate ` | Browser TLS fingerprint per source platform (see [`crate::platform::Platform::impersonate_target`]) | //! | `--break-on-existing` | Stop when the archive file records the video as already downloaded | //! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading | @@ -317,6 +318,10 @@ pub struct Downloader { /// Global `backup.sponsorblock_mode` ("off" / "mark" / "remove"). /// Per-channel options can override. Set at construction + on save. pub sponsorblock_mode: String, + /// Global `backup.fetch_comments`. When true, downloads pass + /// `--write-comments`. Per-channel options can override. Set at + /// construction + on settings save. + pub fetch_comments: 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, @@ -363,6 +368,7 @@ impl Downloader { subtitle_defaults: crate::config::SubtitlesSection::default(), youtube_player_clients: String::new(), sponsorblock_mode: "mark".to_string(), + fetch_comments: false, convert_defaults: crate::config::ConvertSection::default(), retry_queue: Vec::new(), rate_limited_backoff: false, @@ -411,6 +417,18 @@ impl Downloader { } } + /// Add `--write-comments` when comment fetching is enabled, merging the + /// per-channel override (`Some(true)`/`Some(false)`) with the global + /// `fetch_comments` default (used when the override is `None`). + fn apply_comments(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { + let enabled = opts + .and_then(|o| o.fetch_comments) + .unwrap_or(self.fetch_comments); + if enabled { + cmd.arg("--write-comments"); + } + } + fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { let g = &self.subtitle_defaults; // Per-channel override-or-global for each knob. @@ -763,6 +781,8 @@ impl Downloader { self.apply_player_client(&mut cmd, channel_options); // SponsorBlock: global default + per-channel override (off/mark/remove). self.apply_sponsorblock(&mut cmd, channel_options); + // Comment fetching: global backup.fetch_comments + per-channel override. + self.apply_comments(&mut cmd, channel_options); // Post-download conversion: resolve global [convert] + per-channel // override. When active, ask yt-dlp to print each finished file's // final path (after_move) so we can enqueue an ffmpeg pass on it. diff --git a/src/fingerprint.rs b/src/fingerprint.rs index b81efac..ae68e3b 100644 --- a/src/fingerprint.rs +++ b/src/fingerprint.rs @@ -114,6 +114,18 @@ pub struct FpComputed { pub hashes: Vec, } +/// How many worker threads the dedup/fingerprint pass should use by default. +/// Deliberately leaves CPU headroom — each worker also spawns an ffmpeg +/// process for frame extraction, and the pass is a background maintenance job +/// that must not starve the UI, downloads, or scans. Uses roughly half the +/// cores, clamped to `[1, cores-1]` so at least one core stays free. +pub fn default_workers() -> usize { + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + (cores / 2).clamp(1, cores.saturating_sub(1).max(1)) +} + /// Fingerprint many videos in parallel, bumping `progress` after each one so a /// UI can show "N / total". Mirrors the library scanner's hand-rolled worker /// pool (no rayon dependency). @@ -312,6 +324,17 @@ pub fn rebuild_and_group( mod tests { use super::*; + #[test] + fn default_workers_leaves_headroom() { + let w = default_workers(); + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + assert!(w >= 1, "must use at least one worker"); + // On any multi-core machine at least one core stays free. + if cores > 1 { + assert!(w < cores, "should leave a core free (w={w}, cores={cores})"); + } + } + #[test] fn dhash_gradients() { // Strictly increasing rows → every pixel < its right neighbour → no diff --git a/src/web.rs b/src/web.rs index 89f417f..d280a00 100644 --- a/src/web.rs +++ b/src/web.rs @@ -416,6 +416,10 @@ struct SettingsPayload { /// SponsorBlock mode: "off" / "mark" / "remove". #[serde(default)] sponsorblock_mode: String, + /// Global comment fetching (`--write-comments`). Per-channel overrides + /// live in each channel's DownloadOptions, not here. + #[serde(default)] + fetch_comments: bool, /// Global [convert] settings, round-tripped on GET + POST. #[serde(default)] convert_mode: String, @@ -453,6 +457,22 @@ fn file_url(library_root: &StdPath, full: &StdPath) -> Option { Some(format!("/files/{}", parts.join("/"))) } +/// Like `file_url` but targets the `/api/sub-vtt/…` endpoint. All subtitle +/// tracks are routed through that handler (not the raw `/files/` mount) so +/// their cue position/alignment settings get stripped before the player +/// renders them — otherwise auto-generated captions render left-aligned. +fn sub_vtt_url(library_root: &StdPath, full: &StdPath) -> Option { + let rel = full.strip_prefix(library_root).ok()?; + let mut parts: Vec = Vec::new(); + for c in rel.components() { + if let std::path::Component::Normal(s) = c { + parts.push(percent_encode_segment(s.to_str()?)); + } + } + if parts.is_empty() { return None; } + Some(format!("/api/sub-vtt/{}", parts.join("/"))) +} + fn percent_encode_segment(s: &str) -> String { use std::fmt::Write; let mut out = String::with_capacity(s.len()); @@ -603,7 +623,42 @@ fn srt_to_vtt(srt: &str) -> String { let mut out = String::from("WEBVTT\n\n"); for line in srt.lines() { if line.contains("-->") { - out.push_str(&line.replace(',', ".")); + out.push_str(&strip_cue_settings(&line.replace(',', "."))); + } else { + out.push_str(line); + } + out.push('\n'); + } + out +} + +/// Drop the per-cue position/alignment settings ("align:start position:0%", +/// "line:…", "size:…") that yt-dlp's auto-generated captions append to every +/// cue timing line. Browsers honor them and left-align/offset the text; +/// removing them restores the default centered, bottom rendering. Only the +/// settings *after* the "start --> end" timestamps are removed — the two +/// timestamps are preserved verbatim. +fn strip_cue_settings(timing_line: &str) -> String { + match timing_line.find("-->") { + Some(arrow) => { + let start = timing_line[..arrow].trim_end(); + let after = timing_line[arrow + 3..].trim_start(); + // The end timestamp is the first whitespace-delimited token; any + // cue settings follow it and are dropped. + let end = after.split_whitespace().next().unwrap_or(""); + format!("{start} --> {end}") + } + None => timing_line.to_string(), + } +} + +/// Strip cue settings from an already-WebVTT document (leaves the header, +/// cue identifiers, and text untouched — only timing lines are normalized). +fn normalize_vtt(vtt: &str) -> String { + let mut out = String::with_capacity(vtt.len()); + for line in vtt.lines() { + if line.contains("-->") { + out.push_str(&strip_cue_settings(line)); } else { out.push_str(line); } @@ -817,6 +872,27 @@ mod tests { assert!(vtt.contains("Hello, world")); } + #[test] + fn strip_cue_settings_drops_alignment_keeps_timestamps() { + let line = "00:00:01.000 --> 00:00:03.000 align:start position:0%"; + assert_eq!(strip_cue_settings(line), "00:00:01.000 --> 00:00:03.000"); + // A plain timing line is unchanged. + let plain = "00:00:01.000 --> 00:00:03.000"; + assert_eq!(strip_cue_settings(plain), plain); + // Non-timing lines pass through untouched. + assert_eq!(strip_cue_settings("Hello align:start"), "Hello align:start"); + } + + #[test] + fn normalize_vtt_strips_settings_only_on_timing_lines() { + let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 align:start position:10%\nHello\n"; + let out = normalize_vtt(vtt); + assert!(out.contains("00:00:01.000 --> 00:00:03.000\n")); + assert!(!out.contains("align:start")); + assert!(out.contains("Hello")); + assert!(out.starts_with("WEBVTT")); + } + #[test] fn count_cookies_only_counts_seven_field_lines() { let body = "# Netscape HTTP Cookie File\n\ @@ -1214,14 +1290,9 @@ async fn build_library_payload(state: &Arc) -> LibraryResponse { let thumb_url = v.thumb_path.as_deref().and_then(|p| file_url(root, p)); let subtitles: Vec = v.subtitles.iter() .filter_map(|s| { - let is_srt = s.path.extension().and_then(|e| e.to_str()) == Some("srt"); - let url = if is_srt { - // Route SRT through the on-the-fly conversion endpoint. - let rel = s.path.strip_prefix(root).ok()?; - Some(format!("/api/sub-vtt/{}", rel.display())) - } else { - file_url(root, &s.path) - }?; + // Route every track (SRT and VTT alike) through /api/sub-vtt so + // cue settings are stripped and SRT is converted on the fly. + let url = sub_vtt_url(root, &s.path)?; Some(SubtitleInfo { lang: s.lang.clone(), label: lang_label(&s.lang), @@ -1444,6 +1515,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { let subs = cfg.subtitles.clone(); 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 convert = cfg.convert.clone(); drop(cfg); @@ -1483,6 +1555,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { subtitle_langs: subs.langs, youtube_player_clients: player_clients_out.clone(), sponsorblock_mode: sponsorblock_out.clone(), + fetch_comments: fetch_comments_out, convert_mode: convert.mode.clone(), convert_crf: convert.crf, convert_preset: convert.preset.clone(), @@ -1529,6 +1602,7 @@ async fn post_settings( cfg.subtitles.langs = body.subtitle_langs.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.fetch_comments = body.fetch_comments; // Global format-conversion defaults. cfg.convert.mode = body.convert_mode.trim().to_string(); cfg.convert.crf = body.convert_crf; @@ -1551,6 +1625,7 @@ async fn post_settings( let subs = cfg.subtitles.clone(); 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 convert = cfg.convert.clone(); drop(cfg); @@ -1565,6 +1640,7 @@ async fn post_settings( dl.subtitle_defaults = subs.clone(); dl.youtube_player_clients = player_clients_out.clone(); dl.sponsorblock_mode = sponsorblock_out.clone(); + dl.fetch_comments = fetch_comments_out; dl.convert_defaults = convert.clone(); } @@ -1622,6 +1698,7 @@ async fn post_settings( subtitle_langs: subs.langs, youtube_player_clients: player_clients_out.clone(), sponsorblock_mode: sponsorblock_out.clone(), + fetch_comments: fetch_comments_out, convert_mode: convert.mode.clone(), convert_crf: convert.crf, convert_preset: convert.preset.clone(), @@ -1630,7 +1707,9 @@ async fn post_settings( }).into_response() } -/// `GET /api/sub-vtt/*path` — serve an SRT subtitle file as WebVTT. +/// `GET /api/sub-vtt/*path` — serve a subtitle file as WebVTT: SRT is +/// converted on the fly, VTT is normalized; both have per-cue position/ +/// alignment settings stripped so captions render centered, not left-aligned. /// /// The path is relative to the channels root. The file must be within the /// channels root (path traversal is rejected with 403). @@ -1651,7 +1730,8 @@ async fn get_sub_vtt( Ok(s) => s, Err(_) => return StatusCode::NOT_FOUND.into_response(), }; - let vtt = srt_to_vtt(&content); + let is_srt = path.extension().and_then(|e| e.to_str()) == Some("srt"); + let vtt = if is_srt { srt_to_vtt(&content) } else { normalize_vtt(&content) }; ([(header::CONTENT_TYPE, "text/vtt; charset=utf-8")], vtt).into_response() } @@ -2226,7 +2306,7 @@ fn run_dedup( by_path: HashMap, valid_paths: HashSet, ) { - let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let workers = crate::fingerprint::default_workers(); let outcome: Result, String> = crate::fingerprint::rebuild_and_group(&db, inputs, &valid_paths, workers, &dedup.done, &dedup.total) .map(|path_groups| { @@ -2853,6 +2933,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { downloader.subtitle_defaults = config.subtitles.clone(); 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.convert_defaults = config.convert.clone(); let music_root = downloader.music_root(); let _ = std::fs::create_dir_all(&music_root); diff --git a/src/web_ui/index.html b/src/web_ui/index.html index ec6290b..125bf45 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -142,7 +142,7 @@ .dl-new-flags{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)} .dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap} .preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0} - .empty{text-align:center;color:var(--muted);padding:40px;font-size:13px} + .empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px;font-size:13px} @media(max-width:640px){ #menu-btn{display:block} aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px} @@ -776,7 +776,7 @@ function renderChannelGrid(){ const chs=q?library.filter(ch=>ch.name.toLowerCase().includes(q)||ch.uploader?.toLowerCase().includes(q)):library; setStatus(chs.length+' channel'+(chs.length!==1?'s':'')); if(!chs.length){grid.innerHTML='
Nothing here.
';return} - grid.innerHTML='
'+chs.map((ch,i)=>{ + grid.innerHTML='
'+chs.map((ch,i)=>{ const idx=library.indexOf(ch); const thumb=ch.thumb_url?``:'
📺
'; const size=ch.size_bytes>0?` · ${fmtSize(ch.size_bytes)}`:''; @@ -799,7 +799,7 @@ function renderMusicGrid(){ const tracks=q?musicTracks.filter(t=>t.title.toLowerCase().includes(q)||t.artist.toLowerCase().includes(q)):musicTracks; setStatus(tracks.length+' track'+(tracks.length!==1?'s':'')); if(!tracks.length){grid.innerHTML='
No music yet. Use 🎵 Music mode in the download bar.
';return} - let currentArtist='',h='
'; + let currentArtist='',h='
'; for(const t of tracks){ if(t.artist!==currentArtist){ currentArtist=t.artist; @@ -994,9 +994,9 @@ async function openChannelOptions(idx){
Useful for music channels — saves disk + skips video re-encode.
-
-
-
Adds --write-comments. Embeds the comment tree into info.json — slow for popular videos but enables the Comments tab in the player.
+
+ ${triSelect('opt-comments',opts.fetch_comments)}
+
Adds --write-comments. Embeds the comment tree into info.json — slow for popular videos but enables the Comments tab in the player. "Default" uses the global setting.
Adds --extractor-args youtubetab:skip=authcheck. Safe for public channels — silences the "playlists that require authentication may not extract correctly" warning without changing which videos are found. Leave off for members-only/private channels, where that warning means your cookies may not be working.
@@ -1058,7 +1058,7 @@ function readChannelOptionsForm(){ return { quality:q?q:null, audio_only:document.getElementById('opt-audio')?.checked||false, - fetch_comments:document.getElementById('opt-comments')?.checked||false, + fetch_comments:triValue('opt-comments'), skip_auth_check:document.getElementById('opt-skipauth')?.checked||false, limit_rate_kb:intOrNull('opt-rate'), min_filesize_mb:intOrNull('opt-minsz'), @@ -1785,6 +1785,11 @@ async function openSettings(){
Uses the community SponsorBlock database for sponsor / intro / self-promo segments. Mark adds skippable chapter markers; Remove cuts the segments out of the saved file. Per-channel overrides live in each channel's options.
+
+ + +
+
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.

Subtitles (global defaults)
Applied to every download. Individual channels can override these in their Channel options dialog.
@@ -2027,12 +2032,13 @@ async function saveSettings(btn){ const subsFormat=document.getElementById('cf-subs-format')?.value||''; 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 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,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,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;