diff --git a/src/app.rs b/src/app.rs index f6961a6..311209d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -226,9 +226,24 @@ struct ChannelOptionsForm { date_after: String, match_filter: String, subtitle_langs: String, // comma-separated + // Per-channel subtitle override tri-states: 0 = use global default, + // 1 = force on, 2 = force off. Map to Option on save. + subs_enabled_idx: usize, + subs_auto_idx: usize, + subs_embed_idx: usize, + subtitle_format: String, // "" = global default extra_args: String, // one per line } +/// Map an `Option` override to the tri-state combo index. +fn tri_to_idx(v: Option) -> usize { + match v { None => 0, Some(true) => 1, Some(false) => 2 } +} +/// Inverse of [`tri_to_idx`]. +fn idx_to_tri(i: usize) -> Option { + match i { 1 => Some(true), 2 => Some(false), _ => None } +} + impl App { pub fn new(cc: &eframe::CreationContext<'_>, tray: Option) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); @@ -292,6 +307,13 @@ impl App { let use_bundled_ytdlp = config.backup.use_bundled_ytdlp; let use_pot_provider = config.backup.use_pot_provider; let browser = config.player.browser.clone(); + // Build the downloader up front so we can seed its subtitle + // defaults from config before it goes into the struct literal. + let mut downloader = Downloader::new( + channels_root.clone(), browser.clone(), max_concurrent, + use_bundled_ytdlp, use_pot_provider, + ); + downloader.subtitle_defaults = config.subtitles.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); let plex_path_str = config.plex.library_path @@ -361,7 +383,7 @@ impl App { note_buffer: String::new(), note_target: None, search: String::new(), - downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider), + downloader, show_downloads: false, current_screen: Screen::Library, dl_url: String::new(), @@ -448,6 +470,10 @@ impl App { date_after: opts.date_after.clone().unwrap_or_default(), match_filter: opts.match_filter.clone().unwrap_or_default(), subtitle_langs: opts.subtitle_langs.join(", "), + subs_enabled_idx: tri_to_idx(opts.subtitles_enabled), + subs_auto_idx: tri_to_idx(opts.subtitles_auto), + subs_embed_idx: tri_to_idx(opts.subtitles_embed), + subtitle_format: opts.subtitle_format.clone().unwrap_or_default(), extra_args: opts.extra_args.join("\n"), } } @@ -480,6 +506,10 @@ impl App { match_filter: trim_opt(&f.match_filter), subtitle_langs: f.subtitle_langs.split(',') .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), + subtitles_enabled: idx_to_tri(f.subs_enabled_idx), + subtitles_auto: idx_to_tri(f.subs_auto_idx), + subtitles_embed: idx_to_tri(f.subs_embed_idx), + subtitle_format: trim_opt(&f.subtitle_format), extra_args: f.extra_args.lines() .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), } @@ -1946,6 +1976,36 @@ impl App { .hint_text("en, ja"), ); + // Per-channel subtitle overrides. "Default" defers to the + // global [subtitles] config; the other choices force the + // behavior for this channel only. + ui.add_space(6.0); + ui.label(egui::RichText::new("Subtitle overrides (Default = use global setting):").small().weak()); + let form = &mut self.channel_options_form; + let tri_combo = |ui: &mut egui::Ui, id: &str, label: &str, idx: &mut usize| { + ui.horizontal(|ui| { + ui.label(label); + egui::ComboBox::from_id_salt(id) + .selected_text(match *idx { 1 => "On", 2 => "Off", _ => "Default" }) + .show_ui(ui, |ui| { + ui.selectable_value(idx, 0, "Default"); + ui.selectable_value(idx, 1, "On"); + ui.selectable_value(idx, 2, "Off"); + }); + }); + }; + tri_combo(ui, "subs_enabled", "Download subtitles", &mut form.subs_enabled_idx); + tri_combo(ui, "subs_auto", "Auto-generated captions", &mut form.subs_auto_idx); + tri_combo(ui, "subs_embed", "Embed into video", &mut form.subs_embed_idx); + ui.horizontal(|ui| { + ui.label("Convert format"); + ui.add( + egui::TextEdit::singleline(&mut form.subtitle_format) + .desired_width(120.0) + .hint_text("global (srt/vtt/ass)"), + ).on_hover_text("Blank = use the global setting. e.g. srt for Plex."); + }); + ui.add_space(6.0); ui.label("Extra yt-dlp args (one per line):"); ui.add( @@ -2522,6 +2582,33 @@ impl App { ui.end_row(); }); + ui.add_space(8.0); + ui.separator(); + ui.heading("Subtitles (global defaults)"); + ui.add_space(4.0); + ui.label( + egui::RichText::new( + "Applied to every download. Individual channels can override these \ + in their Channel options dialog.", + ).small().weak(), + ); + let subs = &mut self.config.subtitles; + ui.checkbox(&mut subs.enabled, "Download subtitles"); + ui.add_enabled_ui(subs.enabled, |ui| { + ui.checkbox(&mut subs.auto_generated, "Include auto-generated (machine) captions"); + ui.checkbox(&mut subs.embed, "Embed subtitles into the video file") + .on_hover_text("--embed-subs: soft subs toggleable in the player, in addition to sidecar files."); + ui.horizontal(|ui| { + ui.label("Languages (comma sep, blank = all):"); + ui.add(egui::TextEdit::singleline(&mut subs.langs).desired_width(160.0).hint_text("en, ja")); + }); + ui.horizontal(|ui| { + ui.label("Convert to format (blank = native):"); + ui.add(egui::TextEdit::singleline(&mut subs.format).desired_width(100.0).hint_text("srt")) + .on_hover_text("--convert-subs. srt is the most player/Plex-compatible."); + }); + }); + ui.add_space(8.0); ui.separator(); ui.heading("Backup"); @@ -2713,6 +2800,7 @@ impl App { self.downloader.max_concurrent = self.config.backup.max_concurrent; self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp; self.downloader.use_pot_provider = self.config.backup.use_pot_provider; + self.downloader.subtitle_defaults = self.config.subtitles.clone(); if dir_changed { self.channels_root = new_dir.clone(); self.library_root = new_dir diff --git a/src/config.rs b/src/config.rs index bb05966..5d222c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,47 @@ pub struct Config { pub web: WebSection, #[serde(default)] pub plex: PlexSection, + #[serde(default)] + pub subtitles: SubtitlesSection, +} + +/// `[subtitles]` table — global subtitle download defaults. Per-channel +/// [`crate::download_options::DownloadOptions`] can override any of these. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SubtitlesSection { + /// Master switch. When false, no subtitle flags are emitted at all. + #[serde(default = "default_true")] + pub enabled: bool, + /// Include auto-generated (machine) captions via `--write-auto-subs`. + /// Off-by-default-ish content is noisy; keep it on globally but let + /// channels turn it off. + #[serde(default = "default_true")] + pub auto_generated: bool, + /// Embed subtitle tracks into the video container (`--embed-subs`), + /// in addition to writing sidecar files. Soft subs, toggleable in + /// the player. + #[serde(default)] + pub embed: bool, + /// Convert downloaded subtitles to this format (`--convert-subs`). + /// Empty = keep native. Common: "srt" (Plex-friendly), "vtt", "ass". + #[serde(default)] + pub format: String, + /// Comma-separated language codes (`--sub-langs`). Empty = all + /// available. e.g. "en", "en,ja", "en.*" for all English variants. + #[serde(default)] + pub langs: String, +} + +impl Default for SubtitlesSection { + fn default() -> Self { + Self { + enabled: true, + auto_generated: true, + embed: false, + format: String::new(), + langs: String::new(), + } + } } /// `[backup]` table — where to store downloaded videos. @@ -49,6 +90,7 @@ pub struct BackupSection { } fn default_max_concurrent() -> usize { 3 } +fn default_true() -> bool { true } /// `[player]` table — external player and browser cookie source. #[derive(Debug, Serialize, Deserialize, Clone)] @@ -178,6 +220,7 @@ impl Config { scheduler: SchedulerSection::default(), web: WebSection::default(), plex: PlexSection::default(), + subtitles: SubtitlesSection::default(), } } } diff --git a/src/download_options.rs b/src/download_options.rs index 45efcd7..ac78719 100644 --- a/src/download_options.rs +++ b/src/download_options.rs @@ -67,6 +67,24 @@ pub struct DownloadOptions { #[serde(default)] pub subtitle_langs: Vec, + /// 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, + #[serde(default)] + pub subtitles_auto: Option, + #[serde(default)] + pub subtitles_embed: Option, + #[serde(default)] + pub subtitle_format: Option, + /// 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`. @@ -131,9 +149,10 @@ impl DownloadOptions { cmd.arg("--match-filter").arg(filter); } } - if !self.subtitle_langs.is_empty() { - cmd.arg("--sub-langs").arg(self.subtitle_langs.join(",")); - } + // 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); @@ -189,17 +208,9 @@ mod tests { assert_eq!(args, vec!["--limit-rate", "500K"]); } - #[test] - fn subtitle_langs_join_with_commas() { - let mut cmd = Command::new("yt-dlp"); - DownloadOptions { - subtitle_langs: vec!["en".into(), "ja".into(), "es".into()], - ..Default::default() - } - .apply(&mut cmd); - let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); - assert_eq!(args, vec!["--sub-langs", "en,ja,es"]); - } + // 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() { diff --git a/src/downloader.rs b/src/downloader.rs index c335fd5..588986c 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -10,7 +10,7 @@ //! | Flag | Purpose | //! |---|---| //! | `--cookies cookies.txt` | Pass browser cookies for age-gated/member videos | -//! | `--write-subs --write-auto-subs` | Download subtitles alongside the video | +//! | `--write-subs` (+ optionally `--write-auto-subs` / `--sub-langs` / `--convert-subs` / `--embed-subs`) | Subtitles, per the `[subtitles]` config + per-channel overrides | //! | `--write-thumbnail` | Download channel/video thumbnails | //! | `--write-description` | Save video description as a sidecar `.description` file | //! | `--write-info-json` | Save full metadata as a `.info.json` sidecar | @@ -191,6 +191,10 @@ pub struct Downloader { /// Running bgutil-pot server child. Lazily spawned in [`Self::start`] /// on the first job after the flag turns on; killed in [`Drop`]. pot_server: Option, + /// Global subtitle defaults from `[subtitles]` config. Per-channel + /// [`DownloadOptions`] override individual fields. Set by the app / + /// web layer at construction and on settings save. + pub subtitle_defaults: crate::config::SubtitlesSection, } impl Downloader { @@ -210,6 +214,46 @@ impl Downloader { use_bundled_ytdlp, use_pot_provider, pot_server: None, + subtitle_defaults: crate::config::SubtitlesSection::default(), + } + } + + /// Resolve global `[subtitles]` config + per-channel overrides into + /// the yt-dlp subtitle flag set, appending to `cmd`. + /// + /// Resolution: a per-channel `Some` wins over the global default for + /// each field. When subtitles are disabled (globally or per-channel), + /// nothing is emitted — yt-dlp then writes no subs. + fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { + let g = &self.subtitle_defaults; + // Per-channel override-or-global for each knob. + let enabled = opts.and_then(|o| o.subtitles_enabled).unwrap_or(g.enabled); + if !enabled { return; } + let auto = opts.and_then(|o| o.subtitles_auto).unwrap_or(g.auto_generated); + let embed = opts.and_then(|o| o.subtitles_embed).unwrap_or(g.embed); + // Format: per-channel Some(non-empty) wins; else global. + let format = opts + .and_then(|o| o.subtitle_format.as_deref()) + .filter(|s| !s.is_empty()) + .unwrap_or(g.format.as_str()); + // Langs: per-channel list wins; else global comma string. + let langs: String = match opts { + Some(o) if !o.subtitle_langs.is_empty() => o.subtitle_langs.join(","), + _ => g.langs.clone(), + }; + + cmd.arg("--write-subs"); + if auto { + cmd.arg("--write-auto-subs"); + } + if !langs.is_empty() { + cmd.arg("--sub-langs").arg(langs); + } + if !format.is_empty() { + cmd.arg("--convert-subs").arg(format); + } + if embed { + cmd.arg("--embed-subs"); } } @@ -455,9 +499,7 @@ impl Downloader { let mut cmd = self.ytdlp_cmd(); cmd.arg("--newline").arg("--no-color"); self.apply_cookie_flags(&mut cmd); - cmd.arg("--write-subs") - .arg("--write-auto-subs") - .arg("--write-thumbnail") + cmd.arg("--write-thumbnail") .arg("--write-description") .arg("--write-info-json") // Don't write channel/playlist-level metafiles (avatar, info.json, @@ -492,6 +534,10 @@ impl Downloader { .arg(archive_path.display().to_string()); self.apply_impersonation(info.platform, &mut cmd); self.apply_platform_extras(info.platform, &mut cmd); + // Subtitle flags: global [subtitles] config merged with per-channel + // overrides. Done here (not in opts.apply) so it works even when a + // channel has no options row. + self.apply_subtitle_flags(&mut cmd, channel_options); // Per-channel option overrides win when present — they're applied // last so a `--limit-rate` / `--match-filter` / passthrough arg from // the channel settings takes priority over the global defaults. @@ -522,9 +568,10 @@ impl Downloader { self.apply_cookie_flags(&mut cmd); cmd.arg("--write-thumbnail") .arg("--write-info-json") - .arg("--write-description") - .arg("--write-subs") - .arg("--write-auto-subs"); + .arg("--write-description"); + // Repair has no channel-options context, so subtitles use the + // global [subtitles] defaults. + self.apply_subtitle_flags(&mut cmd, None); // `repair()` rebuilds a YouTube watch URL from a stored video ID, so // the source platform is always YouTube here regardless of where the // original video lives on disk. @@ -904,5 +951,74 @@ mod tests { let input = "[download] 47.2% of 100MiB"; assert_eq!(redact_sensitive(input, abs), input); } + + // ── Subtitle resolver ──────────────────────────────────────────────── + fn dl_with_subs(subs: crate::config::SubtitlesSection) -> Downloader { + let mut d = Downloader::new( + std::path::PathBuf::from("/tmp"), "none".into(), 1, false, false); + d.subtitle_defaults = subs; + d + } + fn args_of(d: &Downloader, opts: Option<&DownloadOptions>) -> Vec { + let mut cmd = Command::new("yt-dlp"); + d.apply_subtitle_flags(&mut cmd, opts); + cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect() + } + + #[test] + fn subs_global_defaults_emit_write_and_auto() { + let d = dl_with_subs(crate::config::SubtitlesSection::default()); + let args = args_of(&d, None); + assert!(args.contains(&"--write-subs".to_string())); + assert!(args.contains(&"--write-auto-subs".to_string())); + // No langs/format/embed by default. + assert!(!args.contains(&"--embed-subs".to_string())); + assert!(!args.iter().any(|a| a == "--convert-subs")); + } + + #[test] + fn subs_disabled_emits_nothing() { + let mut g = crate::config::SubtitlesSection::default(); + g.enabled = false; + let d = dl_with_subs(g); + assert!(args_of(&d, None).is_empty()); + } + + #[test] + fn subs_global_format_embed_langs() { + let g = crate::config::SubtitlesSection { + enabled: true, auto_generated: false, embed: true, + format: "srt".into(), langs: "en,ja".into(), + }; + let d = dl_with_subs(g); + let args = args_of(&d, None); + assert!(args.contains(&"--write-subs".to_string())); + assert!(!args.contains(&"--write-auto-subs".to_string())); // auto off + assert!(args.windows(2).any(|w| w == ["--sub-langs", "en,ja"])); + assert!(args.windows(2).any(|w| w == ["--convert-subs", "srt"])); + assert!(args.contains(&"--embed-subs".to_string())); + } + + #[test] + fn subs_per_channel_overrides_global() { + // Global: enabled+auto. Channel: force OFF. + let d = dl_with_subs(crate::config::SubtitlesSection::default()); + let opts = DownloadOptions { subtitles_enabled: Some(false), ..Default::default() }; + assert!(args_of(&d, Some(&opts)).is_empty()); + + // Global: auto on. Channel: auto off + embed on + own langs. + let opts2 = DownloadOptions { + subtitles_auto: Some(false), + subtitles_embed: Some(true), + subtitle_langs: vec!["en".into()], + subtitle_format: Some("vtt".into()), + ..Default::default() + }; + let args = args_of(&d, Some(&opts2)); + assert!(!args.contains(&"--write-auto-subs".to_string())); + assert!(args.contains(&"--embed-subs".to_string())); + assert!(args.windows(2).any(|w| w == ["--sub-langs", "en"])); + assert!(args.windows(2).any(|w| w == ["--convert-subs", "vtt"])); + } // URL classification tests live in `platform` now — see its tests module. } diff --git a/src/web.rs b/src/web.rs index 8e0bab1..db2e5b1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2304,13 +2304,14 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { let watched = db.get_watched().unwrap_or_default(); let positions = db.get_positions().unwrap_or_default(); - let downloader = Downloader::new( + let mut downloader = Downloader::new( channels_root.clone(), config.player.browser.clone(), config.backup.max_concurrent, config.backup.use_bundled_ytdlp, config.backup.use_pot_provider, ); + downloader.subtitle_defaults = config.subtitles.clone(); let music_root = downloader.music_root(); let _ = std::fs::create_dir_all(&music_root); let transcode = AtomicBool::new(config.web.transcode);