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

@ -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() {