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:
parent
0814ba0c3b
commit
a8f59fe854
2 changed files with 96 additions and 1 deletions
32
src/web.rs
32
src/web.rs
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue