catacomb/src/download_options.rs
Luna aed577ea2f 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>
2026-06-10 01:43:50 -07:00

300 lines
12 KiB
Rust

//! Per-channel download option overrides.
//!
//! Closes the largest gap with Tartube (its `OptionsManager` — 164 fields
//! attachable to any channel/playlist/video with cascading resolution). We
//! ship a focused v1 with ~10 of the most-used fields. The cascade currently
//! has two levels:
//!
//! - **Channel options** — set per `(platform, handle)` pair via the UI and
//! stored in the `channel_options` SQLite table.
//! - **Global defaults** — what [`crate::downloader::Downloader`] applies
//! when no channel options exist.
//!
//! Future expansion (Phase 1 follow-up): folder-level options + a named
//! "options manager" pool that channels can reference by id.
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::downloader::DownloadQuality;
/// Per-channel overrides applied on top of the standard yt-dlp flags
/// emitted by [`crate::downloader::Downloader::start`]. Every field is
/// optional / empty-by-default so an empty `DownloadOptions` is a no-op.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DownloadOptions {
/// Quality cap for this channel's videos. When `Some`, overrides the
/// quality picker the user chose at submit time *for re-checks
/// originating from the channel*. Explicit submits from the download
/// dialog still use whatever the user selected there.
#[serde(default)]
pub quality: Option<DownloadQuality>,
/// Force audio-only extraction for this channel. Adds
/// `--extract-audio --audio-format best --audio-quality 0`.
#[serde(default)]
pub audio_only: bool,
/// Bandwidth cap in kilobytes per second. Maps to `--limit-rate <N>K`.
/// `None` for no limit.
#[serde(default)]
pub limit_rate_kb: Option<u32>,
/// Skip videos smaller than this many megabytes (yt-dlp `--min-filesize`).
#[serde(default)]
pub min_filesize_mb: Option<u32>,
/// Skip videos larger than this many megabytes (yt-dlp `--max-filesize`).
/// Useful for filtering "music" channels that occasionally drop full
/// concerts you don't want.
#[serde(default)]
pub max_filesize_mb: Option<u32>,
/// Only download videos uploaded on or after this date (`YYYYMMDD`).
/// Maps to yt-dlp `--dateafter`.
#[serde(default)]
pub date_after: Option<String>,
/// Free-form yt-dlp `--match-filter` expression. Power-user feature; the
/// UI surfaces it as a single textbox with a pointer to yt-dlp docs.
#[serde(default)]
pub match_filter: Option<String>,
/// Subtitle languages to fetch. `["en"]` for English-only,
/// `["en", "ja"]` for English + Japanese, etc. Empty for the global
/// default (which is auto + manual via `--write-subs --write-auto-subs`).
#[serde(default)]
pub subtitle_langs: Vec<String>,
/// Per-channel subtitle overrides. Each `None` falls back to the
/// global `[subtitles]` config. Resolved in [`crate::downloader`]'s
/// subtitle-flag builder, not here, because `apply()` doesn't have
/// the global config in scope.
///
/// - `subtitles_enabled`: master on/off for this channel.
/// - `subtitles_auto`: include auto-generated captions.
/// - `subtitles_embed`: embed into the container.
/// - `subtitle_format`: convert to this format (empty string = native).
#[serde(default)]
pub subtitles_enabled: Option<bool>,
#[serde(default)]
pub subtitles_auto: Option<bool>,
#[serde(default)]
pub subtitles_embed: Option<bool>,
#[serde(default)]
pub subtitle_format: Option<String>,
/// Per-channel YouTube player-client override (comma-separated, e.g.
/// "tv,mweb"). `None` defers to the global
/// `backup.youtube_player_clients`. Resolved in the downloader, not
/// `apply()`, since the global default lives in config.
#[serde(default)]
pub youtube_player_clients: Option<String>,
/// Per-channel SponsorBlock override: "off" / "mark" / "remove".
/// `None` defers to the global `backup.sponsorblock_mode`. Resolved in
/// the downloader.
#[serde(default)]
pub sponsorblock_mode: Option<String>,
/// Per-channel post-download conversion mode override. `None` defers
/// to the global `[convert]` config; `Some("off")` forces no
/// conversion for this channel even when the global default is on.
/// Other values: "remux-mp4" / "h264-mp4" / "audio". CRF / preset /
/// audio-format always come from the global config.
#[serde(default)]
pub convert_mode: Option<String>,
/// Raw passthrough — every entry is appended as a separate argument to
/// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent
/// to Tartube's `extra_cmd_string`.
#[serde(default)]
pub extra_args: Vec<String>,
/// 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: Option<bool>,
/// Skip yt-dlp's channel-tab authentication sanity check by passing
/// `--extractor-args youtubetab:skip=authcheck`.
///
/// yt-dlp warns ("Playlists that require authentication may not
/// extract correctly…") when it can't confirm a channel page loaded
/// authenticated. For PUBLIC channels that warning is noise — there's
/// no auth-gated content to miss — so this silences it. Leave OFF for
/// channels where you archive members-only / private content with
/// cookies: there the warning is a real "your cookies aren't working,
/// you may be getting an incomplete list" signal worth keeping.
#[serde(default)]
pub skip_auth_check: bool,
}
impl DownloadOptions {
/// True when every field is at its default. Lets callers cheaply detect
/// an effectively-blank options row and avoid storing it.
pub fn is_empty(&self) -> bool {
self == &DownloadOptions::default()
}
/// Append this channel's option overrides to a yt-dlp `Command`.
/// Called by [`crate::downloader::Downloader::start`] after the standard
/// flag set, so an explicit override here wins.
pub fn apply(&self, cmd: &mut Command) {
if self.audio_only {
// Mirror what `start_music` does: extract the best audio in its
// native format. Don't force a re-encode — that'd lose quality.
cmd.arg("--extract-audio")
.arg("--audio-format").arg("best")
.arg("--audio-quality").arg("0");
}
if let Some(rate) = self.limit_rate_kb {
cmd.arg("--limit-rate").arg(format!("{rate}K"));
}
if let Some(min) = self.min_filesize_mb {
cmd.arg("--min-filesize").arg(format!("{min}M"));
}
if let Some(max) = self.max_filesize_mb {
cmd.arg("--max-filesize").arg(format!("{max}M"));
}
if let Some(date) = &self.date_after {
if !date.is_empty() {
cmd.arg("--dateafter").arg(date);
}
}
if let Some(filter) = &self.match_filter {
if !filter.is_empty() {
cmd.arg("--match-filter").arg(filter);
}
}
// Subtitle flags (langs / write / auto / embed / convert) are
// emitted by the downloader's subtitle resolver, which merges
// these per-channel overrides with the global [subtitles] config.
// apply() handles everything else.
for arg in &self.extra_args {
if !arg.is_empty() {
cmd.arg(arg);
}
}
// 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,
// so they coexist as separate flags without clobbering.
cmd.arg("--extractor-args").arg("youtubetab:skip=authcheck");
}
}
/// Deserialize from the JSON blob stored in `channel_options.options_json`.
/// Bad rows are logged and treated as "no options" so a corrupt or
/// schema-drifted row doesn't take a channel offline.
pub fn from_json(s: &str) -> Self {
match serde_json::from_str::<DownloadOptions>(s) {
Ok(o) => o,
Err(e) => {
eprintln!("channel_options: ignoring malformed row: {e}");
DownloadOptions::default()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_options_emit_nothing() {
let mut cmd = Command::new("yt-dlp");
DownloadOptions::default().apply(&mut cmd);
// The Command has just the program name, no extra args.
let args: Vec<_> = cmd.get_args().collect();
assert!(args.is_empty());
}
#[test]
fn limit_rate_emits_correct_flag() {
let mut cmd = Command::new("yt-dlp");
DownloadOptions {
limit_rate_kb: Some(500),
..Default::default()
}
.apply(&mut cmd);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, vec!["--limit-rate", "500K"]);
}
// Subtitle-flag emission moved to the downloader's resolver (it merges
// these per-channel langs with the global [subtitles] config). The
// resolver's behavior is tested in downloader.rs.
#[test]
fn skip_auth_check_emits_extractor_arg() {
let mut cmd = Command::new("yt-dlp");
DownloadOptions { skip_auth_check: true, ..Default::default() }.apply(&mut cmd);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, vec!["--extractor-args", "youtubetab:skip=authcheck"]);
}
#[test]
fn audio_only_adds_extract_audio_chain() {
let mut cmd = Command::new("yt-dlp");
DownloadOptions {
audio_only: true,
..Default::default()
}
.apply(&mut cmd);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(args.windows(2).any(|w| w == ["--extract-audio".to_string(), "--audio-format".to_string()]));
}
#[test]
fn extra_args_pass_through_verbatim() {
let mut cmd = Command::new("yt-dlp");
DownloadOptions {
extra_args: vec!["--no-cache-dir".into(), "--verbose".into()],
..Default::default()
}
.apply(&mut cmd);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, vec!["--no-cache-dir", "--verbose"]);
}
#[test]
fn json_roundtrip() {
let original = DownloadOptions {
quality: Some(DownloadQuality::Res1080),
audio_only: false,
limit_rate_kb: Some(1024),
subtitle_langs: vec!["en".into()],
extra_args: vec!["--no-mtime".into()],
..Default::default()
};
let json = serde_json::to_string(&original).unwrap();
let roundtrip = DownloadOptions::from_json(&json);
assert_eq!(original, roundtrip);
}
#[test]
fn corrupt_json_falls_back_to_default() {
let parsed = DownloadOptions::from_json("not even json {");
assert_eq!(parsed, DownloadOptions::default());
}
#[test]
fn is_empty_detects_default() {
assert!(DownloadOptions::default().is_empty());
assert!(!DownloadOptions {
audio_only: true,
..Default::default()
}
.is_empty());
}
}