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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-10 01:43:50 -07:00
parent fdc7493eb9
commit aed577ea2f
7 changed files with 245 additions and 50 deletions

View file

@ -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<String> {
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<String> {
let rel = full.strip_prefix(library_root).ok()?;
let mut parts: Vec<String> = 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<WebState>) -> LibraryResponse {
let thumb_url = v.thumb_path.as_deref().and_then(|p| file_url(root, p));
let subtitles: Vec<SubtitleInfo> = 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<Arc<WebState>>) -> 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<Arc<WebState>>) -> 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<String, SimilarVideo>,
valid_paths: HashSet<String>,
) {
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
let workers = crate::fingerprint::default_workers();
let outcome: Result<Vec<SimilarGroup>, 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);