From 3031b5b0e5dbd3ba997039f6bc9e3d39c20788af Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 7 Jun 2026 03:53:49 -0700 Subject: [PATCH] Enhance comment viewer + make SponsorBlock configurable (3.6 + parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment viewer (web UI): the flat tree gains search (highlight + keep ancestor context), sort (threaded / top / newest / oldest), per-thread collapse/expand with collapse-all/expand-all, an OP badge for the uploader, and a "new since last visit" highlight + count (localStorage per-video timestamp). API now returns each comment's `timestamp` and `author_is_uploader`. SponsorBlock: was a hardcoded `--sponsorblock-mark all`. Now a real setting (off / mark / remove) with a per-channel override, threaded through the full five touchpoints — config `[backup].sponsorblock_mode` (default "mark", preserving prior behavior), DownloadOptions override, a downloader resolver (apply_sponsorblock), and both UIs (egui Settings + channel dialog; web Settings modal + channel dialog + SettingsPayload). Integration tests extended: settings round-trip asserts the sponsorblock default + persistence; channel-options round-trip asserts the override. Co-Authored-By: Claude Opus 4.8 --- src/app.rs | 64 +++++++++++++++++++++++ src/config.rs | 9 ++++ src/download_options.rs | 6 +++ src/downloader.rs | 26 ++++++++-- src/web.rs | 16 ++++++ src/web_ui/index.html | 111 ++++++++++++++++++++++++++++++++++------ tests/api.rs | 7 ++- 7 files changed, 219 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index e229635..a544efc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -240,6 +240,9 @@ struct ChannelOptionsForm { // Per-channel convert override: 0=Default(global), 1=Off, 2=remux-mp4, // 3=h264-mp4, 4=audio. Maps to Option on save. convert_idx: usize, + // Per-channel SponsorBlock override: 0=Default(global), 1=Off, 2=mark, + // 3=remove. Maps to Option on save. + sponsorblock_idx: usize, extra_args: String, // one per line } @@ -273,6 +276,25 @@ fn idx_to_convert_mode(i: usize) -> Option { } } +/// Per-channel SponsorBlock override ⇄ combo index. +/// 0=Default (None, defer to global), 1=Off, 2=mark, 3=remove. +fn sponsorblock_to_idx(v: &Option) -> usize { + match v.as_deref() { + Some("off") => 1, + Some("mark") => 2, + Some("remove") => 3, + _ => 0, + } +} +fn idx_to_sponsorblock(i: usize) -> Option { + match i { + 1 => Some("off".into()), + 2 => Some("mark".into()), + 3 => Some("remove".into()), + _ => None, + } +} + impl App { pub fn new(cc: &eframe::CreationContext<'_>, tray: Option) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); @@ -348,6 +370,7 @@ impl App { ); downloader.subtitle_defaults = config.subtitles.clone(); downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); + downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone(); downloader.convert_defaults = config.convert.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); @@ -512,6 +535,7 @@ impl App { subtitle_format: opts.subtitle_format.clone().unwrap_or_default(), youtube_player_clients: opts.youtube_player_clients.clone().unwrap_or_default(), convert_idx: convert_mode_to_idx(&opts.convert_mode), + sponsorblock_idx: sponsorblock_to_idx(&opts.sponsorblock_mode), extra_args: opts.extra_args.join("\n"), } } @@ -550,6 +574,7 @@ impl App { subtitle_format: trim_opt(&f.subtitle_format), youtube_player_clients: trim_opt(&f.youtube_player_clients), convert_mode: idx_to_convert_mode(f.convert_idx), + sponsorblock_mode: idx_to_sponsorblock(f.sponsorblock_idx), extra_args: f.extra_args.lines() .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), } @@ -2092,6 +2117,25 @@ impl App { global Format-conversion setting. CRF / preset / audio-format come \ from the global config."); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("SponsorBlock"); + let label = match form.sponsorblock_idx { + 1 => "Off", 2 => "Mark chapters", 3 => "Remove segments", + _ => "Default (global)", + }; + egui::ComboBox::from_id_salt("ch_sponsorblock") + .selected_text(label) + .show_ui(ui, |ui| { + ui.selectable_value(&mut form.sponsorblock_idx, 0, "Default (global)"); + ui.selectable_value(&mut form.sponsorblock_idx, 1, "Off"); + ui.selectable_value(&mut form.sponsorblock_idx, 2, "Mark chapters"); + ui.selectable_value(&mut form.sponsorblock_idx, 3, "Remove segments"); + }); + }).response.on_hover_text( + "SponsorBlock handling for this channel. Default = use the global \ + setting. Mark = chapter-mark segments; Remove = cut them from the file."); + ui.add_space(6.0); ui.label("Extra yt-dlp args (one per line):"); ui.add( @@ -2579,6 +2623,25 @@ impl App { Per-channel overrides live in each channel's options."); ui.end_row(); + ui.label("SponsorBlock:"); + egui::ComboBox::from_id_salt("global_sponsorblock") + .selected_text(match self.config.backup.sponsorblock_mode.as_str() { + "off" => "Off", + "remove" => "Remove segments", + _ => "Mark chapters", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.config.backup.sponsorblock_mode, "off".to_string(), "Off"); + ui.selectable_value(&mut self.config.backup.sponsorblock_mode, "mark".to_string(), "Mark chapters"); + ui.selectable_value(&mut self.config.backup.sponsorblock_mode, "remove".to_string(), "Remove segments"); + }) + .response.on_hover_text( + "SponsorBlock uses the community database for sponsor / intro / \ + self-promo segments. Mark = add skippable chapter markers; \ + Remove = cut the segments from the saved file. Per-channel \ + overrides live in each channel's options."); + ui.end_row(); + ui.label("Web UI port:"); ui.add( egui::DragValue::new(&mut self.config.web.port) @@ -2987,6 +3050,7 @@ impl App { self.downloader.use_pot_provider = self.config.backup.use_pot_provider; self.downloader.subtitle_defaults = self.config.subtitles.clone(); self.downloader.youtube_player_clients = self.config.backup.youtube_player_clients.clone(); + self.downloader.sponsorblock_mode = self.config.backup.sponsorblock_mode.clone(); self.downloader.convert_defaults = self.config.convert.clone(); if dir_changed { self.channels_root = new_dir.clone(); diff --git a/src/config.rs b/src/config.rs index 7a1356c..7313c3b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,10 +127,18 @@ pub struct BackupSection { /// live in [`crate::download_options::DownloadOptions`]. #[serde(default)] pub youtube_player_clients: String, + /// SponsorBlock handling for downloaded videos. `"mark"` (the default) + /// chapter-marks segments via `--sponsorblock-mark all` so a player can + /// skip them; `"remove"` cuts them from the file via + /// `--sponsorblock-remove all`; `"off"` disables it. Per-channel + /// overrides live in [`crate::download_options::DownloadOptions`]. + #[serde(default = "default_sponsorblock_mode")] + pub sponsorblock_mode: String, } fn default_max_concurrent() -> usize { 3 } fn default_true() -> bool { true } +fn default_sponsorblock_mode() -> String { "mark".to_string() } /// `[player]` table — external player and browser cookie source. #[derive(Debug, Serialize, Deserialize, Clone)] @@ -264,6 +272,7 @@ impl Config { use_bundled_ytdlp: false, use_pot_provider: false, youtube_player_clients: String::new(), + sponsorblock_mode: default_sponsorblock_mode(), }, player: PlayerSection::default(), ui: UiSection::default(), diff --git a/src/download_options.rs b/src/download_options.rs index e64ff90..636c5c3 100644 --- a/src/download_options.rs +++ b/src/download_options.rs @@ -92,6 +92,12 @@ pub struct DownloadOptions { #[serde(default)] pub youtube_player_clients: Option, + /// Per-channel SponsorBlock override: "off" / "mark" / "remove". + /// `None` defers to the global `backup.sponsorblock_mode`. Resolved in + /// the downloader. + #[serde(default)] + pub sponsorblock_mode: Option, + /// Per-channel post-download conversion mode override. `None` defers /// to the global `[convert]` config; `Some("off")` forces no /// conversion for this channel even when the global default is on. diff --git a/src/downloader.rs b/src/downloader.rs index c8ff7db..88d9934 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -17,7 +17,7 @@ //! | `--remux-video mkv` | Re-container to MKV (no re-encode) | //! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV | //! | `--xattrs` | Store metadata in filesystem extended attributes | -//! | `--sponsorblock-mark all` | Mark (but don't remove) SponsorBlock segments | +//! | `--sponsorblock-mark/-remove all` | SponsorBlock handling, per `sponsorblock_mode` (off/mark/remove; see [`Self::apply_sponsorblock`]) | //! | `--impersonate ` | Browser TLS fingerprint per source platform (see [`crate::platform::Platform::impersonate_target`]) | //! | `--break-on-existing` | Stop when the archive file records the video as already downloaded | //! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading | @@ -314,6 +314,9 @@ pub struct Downloader { /// yt-dlp defaults. Per-channel options can override. Set at /// construction + on settings save. pub youtube_player_clients: String, + /// Global `backup.sponsorblock_mode` ("off" / "mark" / "remove"). + /// Per-channel options can override. Set at construction + on save. + pub sponsorblock_mode: String, /// Global `[convert]` config. Drives the post-download ffmpeg pass. /// Per-channel options override the mode. Set at construction + save. pub convert_defaults: crate::config::ConvertSection, @@ -359,6 +362,7 @@ impl Downloader { pot_server: None, subtitle_defaults: crate::config::SubtitlesSection::default(), youtube_player_clients: String::new(), + sponsorblock_mode: "mark".to_string(), convert_defaults: crate::config::ConvertSection::default(), retry_queue: Vec::new(), rate_limited_backoff: false, @@ -391,6 +395,22 @@ impl Downloader { .arg(format!("youtube:player_client={clients}")); } + /// Apply SponsorBlock flags from the resolved mode (per-channel override + /// or global default). "mark" chapter-marks segments, "remove" cuts them, + /// anything else (incl. "off") omits the flags entirely. + fn apply_sponsorblock(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { + let mode = opts + .and_then(|o| o.sponsorblock_mode.as_deref()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(self.sponsorblock_mode.as_str()) + .trim(); + match mode { + "mark" => { cmd.arg("--sponsorblock-mark").arg("all"); } + "remove" => { cmd.arg("--sponsorblock-remove").arg("all"); } + _ => {} // "off" or unknown → no SponsorBlock processing + } + } + fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { let g = &self.subtitle_defaults; // Per-channel override-or-global for each knob. @@ -714,8 +734,6 @@ impl Downloader { .arg("--embed-info-json") .arg("--embed-chapters") .arg("--xattrs") - .arg("--sponsorblock-mark") - .arg("all") .arg("--progress"); if let Some(fmt) = quality.format_spec() { cmd.arg("-f").arg(fmt); @@ -743,6 +761,8 @@ impl Downloader { // YouTube player-client selection (global default + per-channel // override). Lets the user route around a captcha-walled client. self.apply_player_client(&mut cmd, channel_options); + // SponsorBlock: global default + per-channel override (off/mark/remove). + self.apply_sponsorblock(&mut cmd, channel_options); // Post-download conversion: resolve global [convert] + per-channel // override. When active, ask yt-dlp to print each finished file's // final path (after_move) so we can enqueue an ffmpeg pass on it. diff --git a/src/web.rs b/src/web.rs index 61a8f7a..6725329 100644 --- a/src/web.rs +++ b/src/web.rs @@ -371,6 +371,9 @@ struct SettingsPayload { /// YouTube player clients (comma-separated). Empty = yt-dlp defaults. #[serde(default)] youtube_player_clients: String, + /// SponsorBlock mode: "off" / "mark" / "remove". + #[serde(default)] + sponsorblock_mode: String, /// Global [convert] settings, round-tripped on GET + POST. #[serde(default)] convert_mode: String, @@ -1369,6 +1372,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { let use_pot_provider = cfg.backup.use_pot_provider; let subs = cfg.subtitles.clone(); let player_clients_out = cfg.backup.youtube_player_clients.clone(); + let sponsorblock_out = cfg.backup.sponsorblock_mode.clone(); let convert = cfg.convert.clone(); drop(cfg); @@ -1407,6 +1411,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { subtitle_format: subs.format, subtitle_langs: subs.langs, youtube_player_clients: player_clients_out.clone(), + sponsorblock_mode: sponsorblock_out.clone(), convert_mode: convert.mode.clone(), convert_crf: convert.crf, convert_preset: convert.preset.clone(), @@ -1452,6 +1457,7 @@ async fn post_settings( cfg.subtitles.format = body.subtitle_format.trim().to_string(); cfg.subtitles.langs = body.subtitle_langs.trim().to_string(); cfg.backup.youtube_player_clients = body.youtube_player_clients.trim().to_string(); + cfg.backup.sponsorblock_mode = body.sponsorblock_mode.trim().to_string(); // Global format-conversion defaults. cfg.convert.mode = body.convert_mode.trim().to_string(); cfg.convert.crf = body.convert_crf; @@ -1473,6 +1479,7 @@ async fn post_settings( let use_pot_provider = cfg.backup.use_pot_provider; let subs = cfg.subtitles.clone(); let player_clients_out = cfg.backup.youtube_player_clients.clone(); + let sponsorblock_out = cfg.backup.sponsorblock_mode.clone(); let convert = cfg.convert.clone(); drop(cfg); @@ -1486,6 +1493,7 @@ async fn post_settings( dl.use_pot_provider = use_pot_provider; dl.subtitle_defaults = subs.clone(); dl.youtube_player_clients = player_clients_out.clone(); + dl.sponsorblock_mode = sponsorblock_out.clone(); dl.convert_defaults = convert.clone(); } @@ -1542,6 +1550,7 @@ async fn post_settings( subtitle_format: subs.format, subtitle_langs: subs.langs, youtube_player_clients: player_clients_out.clone(), + sponsorblock_mode: sponsorblock_out.clone(), convert_mode: convert.mode.clone(), convert_crf: convert.crf, convert_preset: convert.preset.clone(), @@ -1832,6 +1841,10 @@ async fn get_comments( let time = c.get("_time_text").and_then(|v| v.as_str()) .or_else(|| c.get("time_text").and_then(|v| v.as_str())) .map(String::from); + // Absolute post time (unix seconds) when yt-dlp captured it — + // drives "newest" sorting and the new-since-last-visit highlight. + let timestamp = c.get("timestamp").and_then(|v| v.as_i64()); + let is_uploader = c.get("author_is_uploader").and_then(|v| v.as_bool()); Some(serde_json::json!({ "id": id, "author": author, @@ -1839,6 +1852,8 @@ async fn get_comments( "likes": likes, "parent": parent, "time": time, + "timestamp": timestamp, + "is_uploader": is_uploader, })) }).collect::>() }) @@ -2528,6 +2543,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { ); downloader.subtitle_defaults = config.subtitles.clone(); downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); + downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone(); downloader.convert_defaults = config.convert.clone(); let music_root = downloader.music_root(); let _ = std::fs::create_dir_all(&music_root); diff --git a/src/web_ui/index.html b/src/web_ui/index.html index c91814e..a52bca3 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -1017,6 +1017,10 @@ async function openChannelOptions(idx){
Per-channel --extractor-args youtube:player_client override. If this channel keeps hitting captchas, try tv,mweb (least bot-checked).
+
+
+ + + +
+
+
Loading…
`; document.body.appendChild(bg); + cmt={vid:videoId,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0}; try{ const r=await(await api('/api/comments/'+encodeURIComponent(videoId))).json(); - renderComments(r.comments||[]); + cmt.list=r.comments||[]; + // "New since last visit": remember when this video's comments were last + // opened (unix secs in localStorage) and flag comments posted after that. + const key='cmts:'+videoId; + cmt.prevVisit=parseInt(localStorage.getItem(key)||'0',10)||0; + localStorage.setItem(key,String(Math.floor(Date.now()/1000))); + renderComments(); }catch(e){ - document.getElementById('comments-body').innerHTML=`
Failed to load comments: ${esc(e.message)}
`; + const b=document.getElementById('comments-body'); + if(b)b.innerHTML=`
Failed to load comments: ${esc(e.message)}
`; } } -function renderComments(list){ +function cmtCollapseAll(collapse){ + cmt.collapsed=new Set(); + if(collapse)cmt.list.filter(c=>c.parent).forEach(c=>cmt.collapsed.add(c.parent)); + renderComments(); +} +function cmtToggle(id){ + if(cmt.collapsed.has(id))cmt.collapsed.delete(id);else cmt.collapsed.add(id); + renderComments(); +} +function cmtHl(s){ + const e=esc(s||'');const q=cmt.q.trim(); + if(!q)return e; + try{return e.replace(new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','ig'),'$1')}catch{return e} +} +function renderComments(){ const body=document.getElementById('comments-body');if(!body)return; + const status=document.getElementById('cmt-status'); + const list=cmt.list; if(!list.length){ body.innerHTML='
No comments captured for this video. Enable "Fetch comments" in this channel\'s options + re-download.
'; + if(status)status.textContent=''; return; } - // Build a tree from parent ids. Top-level = parent missing / not in set. + // Build the reply tree (top-level = parent missing / not in set). const byId=new Map(list.map(c=>[c.id,{...c,children:[]}])); const roots=[]; for(const c of byId.values()){ - if(c.parent && byId.has(c.parent)) byId.get(c.parent).children.push(c); + if(c.parent&&byId.has(c.parent))byId.get(c.parent).children.push(c); else roots.push(c); } + const q=cmt.q.trim().toLowerCase(); + const matches=c=>(c.text||'').toLowerCase().includes(q)||(c.author||'').toLowerCase().includes(q); + // While searching: keep matching comments plus their ancestor chain so a + // reply still shows in context. visit() returns true if the subtree matches. + const keep=new Set(); + if(q){ + const visit=c=>{let any=matches(c);for(const ch of c.children)any=visit(ch)||any;if(any)keep.add(c.id);return any}; + roots.forEach(visit); + } + const cmp={top:(a,b)=>(b.likes||0)-(a.likes||0), + new:(a,b)=>(b.timestamp||0)-(a.timestamp||0), + old:(a,b)=>(a.timestamp||0)-(b.timestamp||0)}[cmt.sort]||null; const render=(c,depth)=>{ - const indent=depth*16; - const author=c.author?`${esc(c.author)}`:'unknown'; + if(q&&!keep.has(c.id))return''; + const indent=Math.min(depth,8)*14; + const isNew=cmt.prevVisit&&c.timestamp&&c.timestamp>cmt.prevVisit; + const collapsed=cmt.collapsed.has(c.id)&&!q; + const toggle=c.children.length?`${collapsed?'▸':'▾'}${c.children.length} `:''; + const author=c.author?`${cmtHl(c.author)}`:'unknown'; + const op=c.is_uploader?' OP':''; const meta=[c.time,c.likes!=null?`${c.likes} likes`:null].filter(Boolean).join(' · '); - const replies=c.children.length?c.children.map(r=>render(r,depth+1)).join(''):''; - return `
-
${author}${meta?` · ${esc(meta)}`:''}
-
${esc(c.text)}
-
${replies}`; + const newBadge=isNew?' ● new':''; + const childHtml=collapsed?'':c.children.map(r=>render(r,depth+1)).join(''); + return `
+
${toggle}${author}${op}${meta?` · ${esc(meta)}`:''}${newBadge}
+
${cmtHl(c.text)}
+
${childHtml}`; }; - body.innerHTML=`
${list.length} comment${list.length===1?'':'s'}
`+roots.map(c=>render(c,0)).join(''); + const ordered=cmp?roots.slice().sort(cmp):roots; + body.innerHTML=ordered.map(c=>render(c,0)).join('')||'
No comments match your search.
'; + if(status){ + const newCount=cmt.prevVisit?list.filter(c=>c.timestamp&&c.timestamp>cmt.prevVisit).length:0; + const bits=[`${list.length} comment${list.length===1?'':'s'}`]; + if(newCount)bits.push(`${newCount} new since last visit`); + if(q)bits.push('filtered'); + status.innerHTML=bits.join(' · '); + } } /* ── Select / Details ───────────────────────────────────────────── */ @@ -1542,6 +1613,13 @@ async function openSettings(){
Comma-separated --extractor-args youtube:player_client. Blank = let yt-dlp pick (recommended). YouTube's bot-detection targets different clients over time; if you keep hitting captchas, try tv,mweb — currently the least-checked. Per-channel overrides live in each channel's options.
+
+ + +
+
Uses the community SponsorBlock database for sponsor / intro / self-promo segments. Mark adds skippable chapter markers; Remove cuts the segments out of the saved file. Per-channel overrides live in each channel's options.

Subtitles (global defaults)
Applied to every download. Individual channels can override these in their Channel options dialog.
@@ -1783,12 +1861,13 @@ async function saveSettings(btn){ const subsLangs=document.getElementById('cf-subs-langs')?.value||''; const subsFormat=document.getElementById('cf-subs-format')?.value||''; const playerClients=document.getElementById('cf-player-clients')?.value||''; + const sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark'; const convMode=document.getElementById('cf-convert-mode')?.value||''; const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10); const convPreset=document.getElementById('cf-convert-preset')?.value||''; const convAudio=document.getElementById('cf-convert-audio')?.value||''; const convKeep=document.getElementById('cf-convert-keep')?.checked||false; - 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,youtube_player_clients:playerClients,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep}; + 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,youtube_player_clients:playerClients,sponsorblock_mode:sponsorblock,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep}; if(bindMode)payload.bind_mode=bindMode; if(plexPath!==undefined)payload.plex_library_path=plexPath; if(sourceUrl!==undefined)payload.source_url=sourceUrl; diff --git a/tests/api.rs b/tests/api.rs index 8d7972f..8da6a49 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -207,6 +207,7 @@ fn settings_roundtrip_and_persist() { let (code, body) = s.get("/api/settings"); assert_eq!(code, 200); assert_eq!(field(&body, "convert_mode"), Some(""), "default convert off"); + assert_eq!(field(&body, "sponsorblock_mode"), Some("mark"), "default sponsorblock = mark"); // Flip a few global settings. let (code, _) = s.post("/api/settings", r#"{ @@ -214,6 +215,7 @@ fn settings_roundtrip_and_persist() { "max_concurrent":5,"use_bundled_ytdlp":false,"use_pot_provider":false, "subtitles_enabled":true,"subtitles_auto":false,"subtitles_embed":true, "subtitle_langs":"en","subtitle_format":"srt","youtube_player_clients":"tv,mweb", + "sponsorblock_mode":"remove", "convert_mode":"h264-mp4","convert_crf":28,"convert_preset":"fast", "convert_audio_format":"","convert_keep_original":true }"#); @@ -224,12 +226,14 @@ fn settings_roundtrip_and_persist() { assert_eq!(field(&body, "convert_mode"), Some("h264-mp4")); assert_eq!(field(&body, "convert_crf"), Some("28")); assert_eq!(field(&body, "youtube_player_clients"), Some("tv,mweb")); + assert_eq!(field(&body, "sponsorblock_mode"), Some("remove")); assert_eq!(field(&body, "subtitle_format"), Some("srt")); // …and so does config.toml on disk. let cfg = std::fs::read_to_string(s.dir.join("config.toml")).unwrap(); assert!(cfg.contains("mode = \"h264-mp4\""), "config persisted convert mode:\n{cfg}"); assert!(cfg.contains("youtube_player_clients = \"tv,mweb\""), "config persisted clients"); + assert!(cfg.contains("sponsorblock_mode = \"remove\""), "config persisted sponsorblock"); } #[test] @@ -288,12 +292,13 @@ fn channel_options_roundtrip_and_clear() { // Store an override. let (code, _) = s.post( "/api/channels/channels/SomeChannel/options", - r#"{"convert_mode":"audio","youtube_player_clients":"tv","subtitles_enabled":false}"#, + r#"{"convert_mode":"audio","youtube_player_clients":"tv","sponsorblock_mode":"off","subtitles_enabled":false}"#, ); assert_eq!(code, 200); let (_, opts) = s.get("/api/channels/channels/SomeChannel/options"); assert_eq!(field(&opts, "convert_mode"), Some("audio")); assert_eq!(field(&opts, "youtube_player_clients"), Some("tv")); + assert_eq!(field(&opts, "sponsorblock_mode"), Some("off")); // An all-default body hits the is_empty() delete path → back to defaults. let (code, _) = s.post("/api/channels/channels/SomeChannel/options", r#"{}"#);