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:
parent
1e95102ec8
commit
0814ba0c3b
5 changed files with 282 additions and 23 deletions
90
src/app.rs
90
src/app.rs
|
|
@ -226,9 +226,24 @@ struct ChannelOptionsForm {
|
||||||
date_after: String,
|
date_after: String,
|
||||||
match_filter: String,
|
match_filter: String,
|
||||||
subtitle_langs: String, // comma-separated
|
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
|
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 {
|
impl App {
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>, tray: Option<crate::tray::TrayHandle>) -> Self {
|
pub fn new(cc: &eframe::CreationContext<'_>, tray: Option<crate::tray::TrayHandle>) -> Self {
|
||||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
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_bundled_ytdlp = config.backup.use_bundled_ytdlp;
|
||||||
let use_pot_provider = config.backup.use_pot_provider;
|
let use_pot_provider = config.backup.use_pot_provider;
|
||||||
let browser = config.player.browser.clone();
|
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 config_bind = config.web.bind.clone();
|
||||||
let password_set = db.get_setting("password_hash").ok().flatten().is_some();
|
let password_set = db.get_setting("password_hash").ok().flatten().is_some();
|
||||||
let plex_path_str = config.plex.library_path
|
let plex_path_str = config.plex.library_path
|
||||||
|
|
@ -361,7 +383,7 @@ impl App {
|
||||||
note_buffer: String::new(),
|
note_buffer: String::new(),
|
||||||
note_target: None,
|
note_target: None,
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider),
|
downloader,
|
||||||
show_downloads: false,
|
show_downloads: false,
|
||||||
current_screen: Screen::Library,
|
current_screen: Screen::Library,
|
||||||
dl_url: String::new(),
|
dl_url: String::new(),
|
||||||
|
|
@ -448,6 +470,10 @@ impl App {
|
||||||
date_after: opts.date_after.clone().unwrap_or_default(),
|
date_after: opts.date_after.clone().unwrap_or_default(),
|
||||||
match_filter: opts.match_filter.clone().unwrap_or_default(),
|
match_filter: opts.match_filter.clone().unwrap_or_default(),
|
||||||
subtitle_langs: opts.subtitle_langs.join(", "),
|
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"),
|
extra_args: opts.extra_args.join("\n"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +506,10 @@ impl App {
|
||||||
match_filter: trim_opt(&f.match_filter),
|
match_filter: trim_opt(&f.match_filter),
|
||||||
subtitle_langs: f.subtitle_langs.split(',')
|
subtitle_langs: f.subtitle_langs.split(',')
|
||||||
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
.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()
|
extra_args: f.extra_args.lines()
|
||||||
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
||||||
}
|
}
|
||||||
|
|
@ -1946,6 +1976,36 @@ impl App {
|
||||||
.hint_text("en, ja"),
|
.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.add_space(6.0);
|
||||||
ui.label("Extra yt-dlp args (one per line):");
|
ui.label("Extra yt-dlp args (one per line):");
|
||||||
ui.add(
|
ui.add(
|
||||||
|
|
@ -2522,6 +2582,33 @@ impl App {
|
||||||
ui.end_row();
|
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.add_space(8.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.heading("Backup");
|
ui.heading("Backup");
|
||||||
|
|
@ -2713,6 +2800,7 @@ impl App {
|
||||||
self.downloader.max_concurrent = self.config.backup.max_concurrent;
|
self.downloader.max_concurrent = self.config.backup.max_concurrent;
|
||||||
self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp;
|
self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp;
|
||||||
self.downloader.use_pot_provider = self.config.backup.use_pot_provider;
|
self.downloader.use_pot_provider = self.config.backup.use_pot_provider;
|
||||||
|
self.downloader.subtitle_defaults = self.config.subtitles.clone();
|
||||||
if dir_changed {
|
if dir_changed {
|
||||||
self.channels_root = new_dir.clone();
|
self.channels_root = new_dir.clone();
|
||||||
self.library_root = new_dir
|
self.library_root = new_dir
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,47 @@ pub struct Config {
|
||||||
pub web: WebSection,
|
pub web: WebSection,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plex: PlexSection,
|
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.
|
/// `[backup]` table — where to store downloaded videos.
|
||||||
|
|
@ -49,6 +90,7 @@ pub struct BackupSection {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_concurrent() -> usize { 3 }
|
fn default_max_concurrent() -> usize { 3 }
|
||||||
|
fn default_true() -> bool { true }
|
||||||
|
|
||||||
/// `[player]` table — external player and browser cookie source.
|
/// `[player]` table — external player and browser cookie source.
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
|
@ -178,6 +220,7 @@ impl Config {
|
||||||
scheduler: SchedulerSection::default(),
|
scheduler: SchedulerSection::default(),
|
||||||
web: WebSection::default(),
|
web: WebSection::default(),
|
||||||
plex: PlexSection::default(),
|
plex: PlexSection::default(),
|
||||||
|
subtitles: SubtitlesSection::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,24 @@ pub struct DownloadOptions {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subtitle_langs: Vec<String>,
|
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
|
/// Raw passthrough — every entry is appended as a separate argument to
|
||||||
/// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent
|
/// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent
|
||||||
/// to Tartube's `extra_cmd_string`.
|
/// to Tartube's `extra_cmd_string`.
|
||||||
|
|
@ -131,9 +149,10 @@ impl DownloadOptions {
|
||||||
cmd.arg("--match-filter").arg(filter);
|
cmd.arg("--match-filter").arg(filter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !self.subtitle_langs.is_empty() {
|
// Subtitle flags (langs / write / auto / embed / convert) are
|
||||||
cmd.arg("--sub-langs").arg(self.subtitle_langs.join(","));
|
// 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 {
|
for arg in &self.extra_args {
|
||||||
if !arg.is_empty() {
|
if !arg.is_empty() {
|
||||||
cmd.arg(arg);
|
cmd.arg(arg);
|
||||||
|
|
@ -189,17 +208,9 @@ mod tests {
|
||||||
assert_eq!(args, vec!["--limit-rate", "500K"]);
|
assert_eq!(args, vec!["--limit-rate", "500K"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// Subtitle-flag emission moved to the downloader's resolver (it merges
|
||||||
fn subtitle_langs_join_with_commas() {
|
// these per-channel langs with the global [subtitles] config). The
|
||||||
let mut cmd = Command::new("yt-dlp");
|
// resolver's behavior is tested in downloader.rs.
|
||||||
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"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skip_auth_check_emits_extractor_arg() {
|
fn skip_auth_check_emits_extractor_arg() {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
//! | Flag | Purpose |
|
//! | Flag | Purpose |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | `--cookies cookies.txt` | Pass browser cookies for age-gated/member videos |
|
//! | `--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-thumbnail` | Download channel/video thumbnails |
|
||||||
//! | `--write-description` | Save video description as a sidecar `.description` file |
|
//! | `--write-description` | Save video description as a sidecar `.description` file |
|
||||||
//! | `--write-info-json` | Save full metadata as a `.info.json` sidecar |
|
//! | `--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`]
|
/// Running bgutil-pot server child. Lazily spawned in [`Self::start`]
|
||||||
/// on the first job after the flag turns on; killed in [`Drop`].
|
/// on the first job after the flag turns on; killed in [`Drop`].
|
||||||
pot_server: Option<std::process::Child>,
|
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 {
|
impl Downloader {
|
||||||
|
|
@ -210,6 +214,46 @@ impl Downloader {
|
||||||
use_bundled_ytdlp,
|
use_bundled_ytdlp,
|
||||||
use_pot_provider,
|
use_pot_provider,
|
||||||
pot_server: None,
|
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();
|
let mut cmd = self.ytdlp_cmd();
|
||||||
cmd.arg("--newline").arg("--no-color");
|
cmd.arg("--newline").arg("--no-color");
|
||||||
self.apply_cookie_flags(&mut cmd);
|
self.apply_cookie_flags(&mut cmd);
|
||||||
cmd.arg("--write-subs")
|
cmd.arg("--write-thumbnail")
|
||||||
.arg("--write-auto-subs")
|
|
||||||
.arg("--write-thumbnail")
|
|
||||||
.arg("--write-description")
|
.arg("--write-description")
|
||||||
.arg("--write-info-json")
|
.arg("--write-info-json")
|
||||||
// Don't write channel/playlist-level metafiles (avatar, info.json,
|
// Don't write channel/playlist-level metafiles (avatar, info.json,
|
||||||
|
|
@ -492,6 +534,10 @@ impl Downloader {
|
||||||
.arg(archive_path.display().to_string());
|
.arg(archive_path.display().to_string());
|
||||||
self.apply_impersonation(info.platform, &mut cmd);
|
self.apply_impersonation(info.platform, &mut cmd);
|
||||||
self.apply_platform_extras(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
|
// Per-channel option overrides win when present — they're applied
|
||||||
// last so a `--limit-rate` / `--match-filter` / passthrough arg from
|
// last so a `--limit-rate` / `--match-filter` / passthrough arg from
|
||||||
// the channel settings takes priority over the global defaults.
|
// the channel settings takes priority over the global defaults.
|
||||||
|
|
@ -522,9 +568,10 @@ impl Downloader {
|
||||||
self.apply_cookie_flags(&mut cmd);
|
self.apply_cookie_flags(&mut cmd);
|
||||||
cmd.arg("--write-thumbnail")
|
cmd.arg("--write-thumbnail")
|
||||||
.arg("--write-info-json")
|
.arg("--write-info-json")
|
||||||
.arg("--write-description")
|
.arg("--write-description");
|
||||||
.arg("--write-subs")
|
// Repair has no channel-options context, so subtitles use the
|
||||||
.arg("--write-auto-subs");
|
// global [subtitles] defaults.
|
||||||
|
self.apply_subtitle_flags(&mut cmd, None);
|
||||||
// `repair()` rebuilds a YouTube watch URL from a stored video ID, so
|
// `repair()` rebuilds a YouTube watch URL from a stored video ID, so
|
||||||
// the source platform is always YouTube here regardless of where the
|
// the source platform is always YouTube here regardless of where the
|
||||||
// original video lives on disk.
|
// original video lives on disk.
|
||||||
|
|
@ -904,5 +951,74 @@ mod tests {
|
||||||
let input = "[download] 47.2% of 100MiB";
|
let input = "[download] 47.2% of 100MiB";
|
||||||
assert_eq!(redact_sensitive(input, abs), input);
|
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.
|
// URL classification tests live in `platform` now — see its tests module.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2304,13 +2304,14 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
let watched = db.get_watched().unwrap_or_default();
|
let watched = db.get_watched().unwrap_or_default();
|
||||||
let positions = db.get_positions().unwrap_or_default();
|
let positions = db.get_positions().unwrap_or_default();
|
||||||
|
|
||||||
let downloader = Downloader::new(
|
let mut downloader = Downloader::new(
|
||||||
channels_root.clone(),
|
channels_root.clone(),
|
||||||
config.player.browser.clone(),
|
config.player.browser.clone(),
|
||||||
config.backup.max_concurrent,
|
config.backup.max_concurrent,
|
||||||
config.backup.use_bundled_ytdlp,
|
config.backup.use_bundled_ytdlp,
|
||||||
config.backup.use_pot_provider,
|
config.backup.use_pot_provider,
|
||||||
);
|
);
|
||||||
|
downloader.subtitle_defaults = config.subtitles.clone();
|
||||||
let music_root = downloader.music_root();
|
let music_root = downloader.music_root();
|
||||||
let _ = std::fs::create_dir_all(&music_root);
|
let _ = std::fs::create_dir_all(&music_root);
|
||||||
let transcode = AtomicBool::new(config.web.transcode);
|
let transcode = AtomicBool::new(config.web.transcode);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue