Subtitle settings: web UI (global section + per-channel overrides)

Completes the subtitle feature started in 0814ba0 (backend + desktop).

## Global settings (web Settings modal)
- New "Subtitles (global defaults)" section: enable toggle that reveals
  auto-captions / embed / languages / convert-format controls.
- SettingsPayload gains the 5 subtitle fields; get_settings reads them
  from [subtitles] config, post_settings writes them back, saves config,
  and pushes the live value onto downloader.subtitle_defaults.

## Per-channel overrides (channel-options dialog)
- Tri-state selects (Default / On / Off) for download-subtitles,
  auto-captions, embed; a convert-format text field. "Default" defers
  to the global setting.
- triSelect() / triValue() helpers map Option<bool> ⇄ select value.
  readChannelOptionsForm includes the four new fields; they serialize
  straight onto DownloadOptions via serde (post_channel_options already
  takes the whole struct).

## Verified end-to-end (live server)
- Global: GET defaults → POST (auto off, embed on, langs=en, format=srt)
  → echoed back → written to config.toml [subtitles] → persists across
  restart.
- Per-channel: POST overrides → read back exactly; all-default body
  hits the is_empty() delete path and clears the row.

84 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-01 06:07:19 -07:00
parent 0814ba0c3b
commit a8f59fe854
2 changed files with 96 additions and 1 deletions

View file

@ -354,6 +354,19 @@ struct SettingsPayload {
/// Whether the bgutil-pot binary is installed on disk (sent by server only). /// Whether the bgutil-pot binary is installed on disk (sent by server only).
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
pot_provider_installed: bool, pot_provider_installed: bool,
/// Global subtitle defaults (the `[subtitles]` config section).
/// Round-trips on both GET and POST. Per-channel overrides live in
/// each channel's DownloadOptions, not here.
#[serde(default)]
subtitles_enabled: bool,
#[serde(default)]
subtitles_auto: bool,
#[serde(default)]
subtitles_embed: bool,
#[serde(default)]
subtitle_format: String,
#[serde(default)]
subtitle_langs: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
@ -1206,6 +1219,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let max_concurrent = cfg.backup.max_concurrent; let max_concurrent = cfg.backup.max_concurrent;
let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp; let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp;
let use_pot_provider = cfg.backup.use_pot_provider; let use_pot_provider = cfg.backup.use_pot_provider;
let subs = cfg.subtitles.clone();
drop(cfg); drop(cfg);
let scheduler_next_check_secs = if scheduler_enabled { let scheduler_next_check_secs = if scheduler_enabled {
@ -1237,6 +1251,11 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
bundled_ytdlp_installed: crate::ytdlp_bin::bundled_installed(), bundled_ytdlp_installed: crate::ytdlp_bin::bundled_installed(),
use_pot_provider, use_pot_provider,
pot_provider_installed: crate::pot_provider::installed(), pot_provider_installed: crate::pot_provider::installed(),
subtitles_enabled: subs.enabled,
subtitles_auto: subs.auto_generated,
subtitles_embed: subs.embed,
subtitle_format: subs.format,
subtitle_langs: subs.langs,
}) })
} }
@ -1270,6 +1289,12 @@ async fn post_settings(
} }
cfg.backup.use_bundled_ytdlp = body.use_bundled_ytdlp; cfg.backup.use_bundled_ytdlp = body.use_bundled_ytdlp;
cfg.backup.use_pot_provider = body.use_pot_provider; cfg.backup.use_pot_provider = body.use_pot_provider;
// Global subtitle defaults.
cfg.subtitles.enabled = body.subtitles_enabled;
cfg.subtitles.auto_generated = body.subtitles_auto;
cfg.subtitles.embed = body.subtitles_embed;
cfg.subtitles.format = body.subtitle_format.trim().to_string();
cfg.subtitles.langs = body.subtitle_langs.trim().to_string();
if let Err(e) = cfg.save(&state.config_path) { if let Err(e) = cfg.save(&state.config_path) {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response(); return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response();
@ -1283,6 +1308,7 @@ async fn post_settings(
let max_concurrent = cfg.backup.max_concurrent; let max_concurrent = cfg.backup.max_concurrent;
let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp; let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp;
let use_pot_provider = cfg.backup.use_pot_provider; let use_pot_provider = cfg.backup.use_pot_provider;
let subs = cfg.subtitles.clone();
drop(cfg); drop(cfg);
// Apply the new concurrency limit and binary choices to the live downloader. // Apply the new concurrency limit and binary choices to the live downloader.
@ -1293,6 +1319,7 @@ async fn post_settings(
} }
dl.use_bundled_ytdlp = use_bundled_ytdlp; dl.use_bundled_ytdlp = use_bundled_ytdlp;
dl.use_pot_provider = use_pot_provider; dl.use_pot_provider = use_pot_provider;
dl.subtitle_defaults = subs.clone();
} }
if let Some(new_pwd) = &body.new_download_password { if let Some(new_pwd) = &body.new_download_password {
@ -1342,6 +1369,11 @@ async fn post_settings(
bundled_ytdlp_installed: crate::ytdlp_bin::bundled_installed(), bundled_ytdlp_installed: crate::ytdlp_bin::bundled_installed(),
use_pot_provider, use_pot_provider,
pot_provider_installed: crate::pot_provider::installed(), pot_provider_installed: crate::pot_provider::installed(),
subtitles_enabled: subs.enabled,
subtitles_auto: subs.auto_generated,
subtitles_embed: subs.embed,
subtitle_format: subs.format,
subtitle_langs: subs.langs,
}).into_response() }).into_response()
} }

View file

@ -882,6 +882,24 @@ function rebuildChannelUrl(ch){
async function downloadChannelByIdx(i){await downloadChannel(rebuildChannelUrl(library[i]))} async function downloadChannelByIdx(i){await downloadChannel(rebuildChannelUrl(library[i]))}
/* ── Per-channel download options ──────────────────────────────── */ /* ── Per-channel download options ──────────────────────────────── */
// Tri-state select for an Option<bool> override. `v` is true / false /
// null|undefined (= use global default). Returns the <select> markup.
function triSelect(id,v){
const sel=(want)=>v===want?' selected':'';
// null/undefined → Default; true → On; false → Off.
const dflt=(v===true||v===false)?'':' selected';
return `<select id="${id}" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:3px 6px;font-size:12px">`
+`<option value=""${dflt}>Default</option>`
+`<option value="on"${sel(true)}>On</option>`
+`<option value="off"${sel(false)}>Off</option>`
+`</select>`;
}
// Read a tri-state select back into an Option<bool>: "" → null, "on" →
// true, "off" → false.
function triValue(id){
const v=document.getElementById(id)?.value;
return v==='on'?true:v==='off'?false:null;
}
async function openChannelOptions(idx){ async function openChannelOptions(idx){
const ch=library[idx]; if(!ch)return; const ch=library[idx]; if(!ch)return;
const platform=ch.platform, handle=ch.name; const platform=ch.platform, handle=ch.name;
@ -930,6 +948,15 @@ async function openChannelOptions(idx){
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px"> <div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Subtitle languages (comma separated, blank = all)</label> <label>Subtitle languages (comma separated, blank = all)</label>
<input type="text" id="opt-subs" value="${esc(langs)}" placeholder="en, ja" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div> <input type="text" id="opt-subs" value="${esc(langs)}" placeholder="en, ja" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-hint" style="margin:2px 0 4px"><strong>Subtitle overrides</strong> — "Default" uses the global setting; the others force the behavior for this channel.</div>
<div class="settings-row"><label>Download subtitles</label>
${triSelect('opt-subs-enabled',opts.subtitles_enabled)}</div>
<div class="settings-row"><label>Auto-generated captions</label>
${triSelect('opt-subs-auto',opts.subtitles_auto)}</div>
<div class="settings-row"><label>Embed into video</label>
${triSelect('opt-subs-embed',opts.subtitles_embed)}</div>
<div class="settings-row"><label>Convert format (blank = global)</label>
<input type="text" id="opt-subs-format" value="${esc(opts.subtitle_format||'')}" placeholder="srt/vtt/ass" style="width:100px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px"> <div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Extra yt-dlp args (one per line)</label> <label>Extra yt-dlp args (one per line)</label>
<textarea id="opt-extra" rows="3" placeholder="--no-mtime <textarea id="opt-extra" rows="3" placeholder="--no-mtime
@ -959,6 +986,10 @@ function readChannelOptionsForm(){
date_after:strOrNull('opt-date'), date_after:strOrNull('opt-date'),
match_filter:strOrNull('opt-filter'), match_filter:strOrNull('opt-filter'),
subtitle_langs:subs, subtitle_langs:subs,
subtitles_enabled:triValue('opt-subs-enabled'),
subtitles_auto:triValue('opt-subs-auto'),
subtitles_embed:triValue('opt-subs-embed'),
subtitle_format:strOrNull('opt-subs-format'),
extra_args:extra, extra_args:extra,
}; };
} }
@ -1429,6 +1460,33 @@ async function openSettings(){
<span id="pot-status" style="font-size:11px;color:var(--muted)"></span> <span id="pot-status" style="font-size:11px;color:var(--muted)"></span>
</div> </div>
<hr style="border-color:var(--border);margin:12px 0"> <hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div>
<div class="settings-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div>
<div class="settings-row">
<label for="cf-subs-enabled">Download subtitles</label>
<input type="checkbox" id="cf-subs-enabled" ${cur.subtitles_enabled?'checked':''} onchange="document.getElementById('cf-subs-detail').style.display=this.checked?'block':'none'">
</div>
<div id="cf-subs-detail" style="display:${cur.subtitles_enabled?'block':'none'};padding-left:8px;border-left:2px solid var(--border)">
<div class="settings-row">
<label for="cf-subs-auto">Include auto-generated (machine) captions</label>
<input type="checkbox" id="cf-subs-auto" ${cur.subtitles_auto?'checked':''}>
</div>
<div class="settings-row">
<label for="cf-subs-embed">Embed subtitles into the video file</label>
<input type="checkbox" id="cf-subs-embed" ${cur.subtitles_embed?'checked':''}>
</div>
<div class="settings-hint" style="margin-bottom:4px">--embed-subs: soft subs toggleable in the player, in addition to sidecar files.</div>
<div class="settings-row">
<label for="cf-subs-langs">Languages (comma sep, blank = all)</label>
<input type="text" id="cf-subs-langs" value="${esc(cur.subtitle_langs||'')}" placeholder="en, ja" style="width:120px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div class="settings-row">
<label for="cf-subs-format">Convert to format (blank = native)</label>
<input type="text" id="cf-subs-format" value="${esc(cur.subtitle_format||'')}" placeholder="srt" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div class="settings-hint" style="margin-bottom:4px">--convert-subs. srt is the most player/Plex-compatible.</div>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Plex</div> <div style="font-weight:700;margin-bottom:8px">Plex</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px"> <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Library path</label> <label>Library path</label>
@ -1595,7 +1653,12 @@ async function saveSettings(btn){
const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false; const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false;
const usePotProvider=document.getElementById('cf-pot-provider')?.checked||false; const usePotProvider=document.getElementById('cf-pot-provider')?.checked||false;
const sourceUrl=document.getElementById('cf-source-url')?.value; const sourceUrl=document.getElementById('cf-source-url')?.value;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider}; const subsEnabled=document.getElementById('cf-subs-enabled')?.checked||false;
const subsAuto=document.getElementById('cf-subs-auto')?.checked||false;
const subsEmbed=document.getElementById('cf-subs-embed')?.checked||false;
const subsLangs=document.getElementById('cf-subs-langs')?.value||'';
const subsFormat=document.getElementById('cf-subs-format')?.value||'';
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat};
if(bindMode)payload.bind_mode=bindMode; if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath; if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl; if(sourceUrl!==undefined)payload.source_url=sourceUrl;