Subtitle settings: global [subtitles] config + per-channel overrides

Adds full subtitle control (backend + desktop UI; web UI follows).

## Model
- New [subtitles] config section: enabled / auto_generated / embed /
  format / langs. Sensible defaults (subs on, auto on, no embed/convert,
  all langs).
- DownloadOptions gains per-channel Option overrides: subtitles_enabled,
  subtitles_auto, subtitles_embed, subtitle_format (subtitle_langs
  already existed). None = defer to global.

## Resolver
- Downloader gains subtitle_defaults (the global section) + a single
  apply_subtitle_flags() that merges global + per-channel and emits the
  yt-dlp flags (--write-subs / --write-auto-subs / --sub-langs /
  --convert-subs / --embed-subs). Disabled = emit nothing.
- Replaces the hardcoded --write-subs --write-auto-subs at the main
  download + repair builders. Moved the --sub-langs emission out of
  DownloadOptions::apply into the resolver so global + override merge in
  one place.

## Desktop UI
- Settings screen: "Subtitles (global defaults)" section.
- Channel-options dialog: tri-state (Default/On/Off) combos for each
  override + a convert-format field.

## Wiring
- subtitle_defaults seeded at Downloader construction (app + web) and
  refreshed on settings save (desktop).

4 resolver tests cover global defaults, disabled, global format/embed/
langs, and per-channel override-wins. 84 pass.

Web UI rows are the remaining piece (next commit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-01 05:44:33 -07:00
parent 1e95102ec8
commit 0814ba0c3b
5 changed files with 282 additions and 23 deletions

View file

@ -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<bool> 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<bool>` override to the tri-state combo index.
fn tri_to_idx(v: Option<bool>) -> usize {
match v { None => 0, Some(true) => 1, Some(false) => 2 }
}
/// Inverse of [`tri_to_idx`].
fn idx_to_tri(i: usize) -> Option<bool> {
match i { 1 => Some(true), 2 => Some(false), _ => None }
}
impl App {
pub fn new(cc: &eframe::CreationContext<'_>, tray: Option<crate::tray::TrayHandle>) -> 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

View file

@ -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(),
}
}
}

View file

@ -67,6 +67,24 @@ pub struct DownloadOptions {
#[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>,
/// 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<String> = 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() {

View file

@ -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<std::process::Child>,
/// 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<String> {
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.
}

View file

@ -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);