Enhance comment viewer + make SponsorBlock configurable (3.6 + parity)
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 <noreply@anthropic.com>
This commit is contained in:
parent
6f174f703b
commit
3031b5b0e5
7 changed files with 219 additions and 20 deletions
64
src/app.rs
64
src/app.rs
|
|
@ -240,6 +240,9 @@ struct ChannelOptionsForm {
|
||||||
// Per-channel convert override: 0=Default(global), 1=Off, 2=remux-mp4,
|
// Per-channel convert override: 0=Default(global), 1=Off, 2=remux-mp4,
|
||||||
// 3=h264-mp4, 4=audio. Maps to Option<String> on save.
|
// 3=h264-mp4, 4=audio. Maps to Option<String> on save.
|
||||||
convert_idx: usize,
|
convert_idx: usize,
|
||||||
|
// Per-channel SponsorBlock override: 0=Default(global), 1=Off, 2=mark,
|
||||||
|
// 3=remove. Maps to Option<String> on save.
|
||||||
|
sponsorblock_idx: usize,
|
||||||
extra_args: String, // one per line
|
extra_args: String, // one per line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,6 +276,25 @@ fn idx_to_convert_mode(i: usize) -> Option<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-channel SponsorBlock override ⇄ combo index.
|
||||||
|
/// 0=Default (None, defer to global), 1=Off, 2=mark, 3=remove.
|
||||||
|
fn sponsorblock_to_idx(v: &Option<String>) -> usize {
|
||||||
|
match v.as_deref() {
|
||||||
|
Some("off") => 1,
|
||||||
|
Some("mark") => 2,
|
||||||
|
Some("remove") => 3,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn idx_to_sponsorblock(i: usize) -> Option<String> {
|
||||||
|
match i {
|
||||||
|
1 => Some("off".into()),
|
||||||
|
2 => Some("mark".into()),
|
||||||
|
3 => Some("remove".into()),
|
||||||
|
_ => 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("."));
|
||||||
|
|
@ -348,6 +370,7 @@ impl App {
|
||||||
);
|
);
|
||||||
downloader.subtitle_defaults = config.subtitles.clone();
|
downloader.subtitle_defaults = config.subtitles.clone();
|
||||||
downloader.youtube_player_clients = config.backup.youtube_player_clients.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();
|
downloader.convert_defaults = config.convert.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();
|
||||||
|
|
@ -512,6 +535,7 @@ impl App {
|
||||||
subtitle_format: opts.subtitle_format.clone().unwrap_or_default(),
|
subtitle_format: opts.subtitle_format.clone().unwrap_or_default(),
|
||||||
youtube_player_clients: opts.youtube_player_clients.clone().unwrap_or_default(),
|
youtube_player_clients: opts.youtube_player_clients.clone().unwrap_or_default(),
|
||||||
convert_idx: convert_mode_to_idx(&opts.convert_mode),
|
convert_idx: convert_mode_to_idx(&opts.convert_mode),
|
||||||
|
sponsorblock_idx: sponsorblock_to_idx(&opts.sponsorblock_mode),
|
||||||
extra_args: opts.extra_args.join("\n"),
|
extra_args: opts.extra_args.join("\n"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -550,6 +574,7 @@ impl App {
|
||||||
subtitle_format: trim_opt(&f.subtitle_format),
|
subtitle_format: trim_opt(&f.subtitle_format),
|
||||||
youtube_player_clients: trim_opt(&f.youtube_player_clients),
|
youtube_player_clients: trim_opt(&f.youtube_player_clients),
|
||||||
convert_mode: idx_to_convert_mode(f.convert_idx),
|
convert_mode: idx_to_convert_mode(f.convert_idx),
|
||||||
|
sponsorblock_mode: idx_to_sponsorblock(f.sponsorblock_idx),
|
||||||
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(),
|
||||||
}
|
}
|
||||||
|
|
@ -2092,6 +2117,25 @@ impl App {
|
||||||
global Format-conversion setting. CRF / preset / audio-format come \
|
global Format-conversion setting. CRF / preset / audio-format come \
|
||||||
from the global config.");
|
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.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(
|
||||||
|
|
@ -2579,6 +2623,25 @@ impl App {
|
||||||
Per-channel overrides live in each channel's options.");
|
Per-channel overrides live in each channel's options.");
|
||||||
ui.end_row();
|
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.label("Web UI port:");
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::DragValue::new(&mut self.config.web.port)
|
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.use_pot_provider = self.config.backup.use_pot_provider;
|
||||||
self.downloader.subtitle_defaults = self.config.subtitles.clone();
|
self.downloader.subtitle_defaults = self.config.subtitles.clone();
|
||||||
self.downloader.youtube_player_clients = self.config.backup.youtube_player_clients.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();
|
self.downloader.convert_defaults = self.config.convert.clone();
|
||||||
if dir_changed {
|
if dir_changed {
|
||||||
self.channels_root = new_dir.clone();
|
self.channels_root = new_dir.clone();
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,18 @@ pub struct BackupSection {
|
||||||
/// live in [`crate::download_options::DownloadOptions`].
|
/// live in [`crate::download_options::DownloadOptions`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub youtube_player_clients: String,
|
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_max_concurrent() -> usize { 3 }
|
||||||
fn default_true() -> bool { true }
|
fn default_true() -> bool { true }
|
||||||
|
fn default_sponsorblock_mode() -> String { "mark".to_string() }
|
||||||
|
|
||||||
/// `[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)]
|
||||||
|
|
@ -264,6 +272,7 @@ impl Config {
|
||||||
use_bundled_ytdlp: false,
|
use_bundled_ytdlp: false,
|
||||||
use_pot_provider: false,
|
use_pot_provider: false,
|
||||||
youtube_player_clients: String::new(),
|
youtube_player_clients: String::new(),
|
||||||
|
sponsorblock_mode: default_sponsorblock_mode(),
|
||||||
},
|
},
|
||||||
player: PlayerSection::default(),
|
player: PlayerSection::default(),
|
||||||
ui: UiSection::default(),
|
ui: UiSection::default(),
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ pub struct DownloadOptions {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub youtube_player_clients: Option<String>,
|
pub youtube_player_clients: Option<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
/// Per-channel post-download conversion mode override. `None` defers
|
/// Per-channel post-download conversion mode override. `None` defers
|
||||||
/// to the global `[convert]` config; `Some("off")` forces no
|
/// to the global `[convert]` config; `Some("off")` forces no
|
||||||
/// conversion for this channel even when the global default is on.
|
/// conversion for this channel even when the global default is on.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
//! | `--remux-video mkv` | Re-container to MKV (no re-encode) |
|
//! | `--remux-video mkv` | Re-container to MKV (no re-encode) |
|
||||||
//! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV |
|
//! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV |
|
||||||
//! | `--xattrs` | Store metadata in filesystem extended attributes |
|
//! | `--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 <target>` | Browser TLS fingerprint per source platform (see [`crate::platform::Platform::impersonate_target`]) |
|
//! | `--impersonate <target>` | 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 |
|
//! | `--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 |
|
//! | `--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
|
/// yt-dlp defaults. Per-channel options can override. Set at
|
||||||
/// construction + on settings save.
|
/// construction + on settings save.
|
||||||
pub youtube_player_clients: String,
|
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.
|
/// Global `[convert]` config. Drives the post-download ffmpeg pass.
|
||||||
/// Per-channel options override the mode. Set at construction + save.
|
/// Per-channel options override the mode. Set at construction + save.
|
||||||
pub convert_defaults: crate::config::ConvertSection,
|
pub convert_defaults: crate::config::ConvertSection,
|
||||||
|
|
@ -359,6 +362,7 @@ impl Downloader {
|
||||||
pot_server: None,
|
pot_server: None,
|
||||||
subtitle_defaults: crate::config::SubtitlesSection::default(),
|
subtitle_defaults: crate::config::SubtitlesSection::default(),
|
||||||
youtube_player_clients: String::new(),
|
youtube_player_clients: String::new(),
|
||||||
|
sponsorblock_mode: "mark".to_string(),
|
||||||
convert_defaults: crate::config::ConvertSection::default(),
|
convert_defaults: crate::config::ConvertSection::default(),
|
||||||
retry_queue: Vec::new(),
|
retry_queue: Vec::new(),
|
||||||
rate_limited_backoff: false,
|
rate_limited_backoff: false,
|
||||||
|
|
@ -391,6 +395,22 @@ impl Downloader {
|
||||||
.arg(format!("youtube:player_client={clients}"));
|
.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>) {
|
fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
|
||||||
let g = &self.subtitle_defaults;
|
let g = &self.subtitle_defaults;
|
||||||
// Per-channel override-or-global for each knob.
|
// Per-channel override-or-global for each knob.
|
||||||
|
|
@ -714,8 +734,6 @@ impl Downloader {
|
||||||
.arg("--embed-info-json")
|
.arg("--embed-info-json")
|
||||||
.arg("--embed-chapters")
|
.arg("--embed-chapters")
|
||||||
.arg("--xattrs")
|
.arg("--xattrs")
|
||||||
.arg("--sponsorblock-mark")
|
|
||||||
.arg("all")
|
|
||||||
.arg("--progress");
|
.arg("--progress");
|
||||||
if let Some(fmt) = quality.format_spec() {
|
if let Some(fmt) = quality.format_spec() {
|
||||||
cmd.arg("-f").arg(fmt);
|
cmd.arg("-f").arg(fmt);
|
||||||
|
|
@ -743,6 +761,8 @@ impl Downloader {
|
||||||
// YouTube player-client selection (global default + per-channel
|
// YouTube player-client selection (global default + per-channel
|
||||||
// override). Lets the user route around a captcha-walled client.
|
// override). Lets the user route around a captcha-walled client.
|
||||||
self.apply_player_client(&mut cmd, channel_options);
|
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
|
// Post-download conversion: resolve global [convert] + per-channel
|
||||||
// override. When active, ask yt-dlp to print each finished file's
|
// 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.
|
// final path (after_move) so we can enqueue an ffmpeg pass on it.
|
||||||
|
|
|
||||||
16
src/web.rs
16
src/web.rs
|
|
@ -371,6 +371,9 @@ struct SettingsPayload {
|
||||||
/// YouTube player clients (comma-separated). Empty = yt-dlp defaults.
|
/// YouTube player clients (comma-separated). Empty = yt-dlp defaults.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
youtube_player_clients: String,
|
youtube_player_clients: String,
|
||||||
|
/// SponsorBlock mode: "off" / "mark" / "remove".
|
||||||
|
#[serde(default)]
|
||||||
|
sponsorblock_mode: String,
|
||||||
/// Global [convert] settings, round-tripped on GET + POST.
|
/// Global [convert] settings, round-tripped on GET + POST.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
convert_mode: String,
|
convert_mode: String,
|
||||||
|
|
@ -1369,6 +1372,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
let use_pot_provider = cfg.backup.use_pot_provider;
|
let use_pot_provider = cfg.backup.use_pot_provider;
|
||||||
let subs = cfg.subtitles.clone();
|
let subs = cfg.subtitles.clone();
|
||||||
let player_clients_out = cfg.backup.youtube_player_clients.clone();
|
let player_clients_out = cfg.backup.youtube_player_clients.clone();
|
||||||
|
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
||||||
let convert = cfg.convert.clone();
|
let convert = cfg.convert.clone();
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
|
|
@ -1407,6 +1411,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
subtitle_format: subs.format,
|
subtitle_format: subs.format,
|
||||||
subtitle_langs: subs.langs,
|
subtitle_langs: subs.langs,
|
||||||
youtube_player_clients: player_clients_out.clone(),
|
youtube_player_clients: player_clients_out.clone(),
|
||||||
|
sponsorblock_mode: sponsorblock_out.clone(),
|
||||||
convert_mode: convert.mode.clone(),
|
convert_mode: convert.mode.clone(),
|
||||||
convert_crf: convert.crf,
|
convert_crf: convert.crf,
|
||||||
convert_preset: convert.preset.clone(),
|
convert_preset: convert.preset.clone(),
|
||||||
|
|
@ -1452,6 +1457,7 @@ async fn post_settings(
|
||||||
cfg.subtitles.format = body.subtitle_format.trim().to_string();
|
cfg.subtitles.format = body.subtitle_format.trim().to_string();
|
||||||
cfg.subtitles.langs = body.subtitle_langs.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.youtube_player_clients = body.youtube_player_clients.trim().to_string();
|
||||||
|
cfg.backup.sponsorblock_mode = body.sponsorblock_mode.trim().to_string();
|
||||||
// Global format-conversion defaults.
|
// Global format-conversion defaults.
|
||||||
cfg.convert.mode = body.convert_mode.trim().to_string();
|
cfg.convert.mode = body.convert_mode.trim().to_string();
|
||||||
cfg.convert.crf = body.convert_crf;
|
cfg.convert.crf = body.convert_crf;
|
||||||
|
|
@ -1473,6 +1479,7 @@ async fn post_settings(
|
||||||
let use_pot_provider = cfg.backup.use_pot_provider;
|
let use_pot_provider = cfg.backup.use_pot_provider;
|
||||||
let subs = cfg.subtitles.clone();
|
let subs = cfg.subtitles.clone();
|
||||||
let player_clients_out = cfg.backup.youtube_player_clients.clone();
|
let player_clients_out = cfg.backup.youtube_player_clients.clone();
|
||||||
|
let sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
||||||
let convert = cfg.convert.clone();
|
let convert = cfg.convert.clone();
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
|
|
@ -1486,6 +1493,7 @@ async fn post_settings(
|
||||||
dl.use_pot_provider = use_pot_provider;
|
dl.use_pot_provider = use_pot_provider;
|
||||||
dl.subtitle_defaults = subs.clone();
|
dl.subtitle_defaults = subs.clone();
|
||||||
dl.youtube_player_clients = player_clients_out.clone();
|
dl.youtube_player_clients = player_clients_out.clone();
|
||||||
|
dl.sponsorblock_mode = sponsorblock_out.clone();
|
||||||
dl.convert_defaults = convert.clone();
|
dl.convert_defaults = convert.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1542,6 +1550,7 @@ async fn post_settings(
|
||||||
subtitle_format: subs.format,
|
subtitle_format: subs.format,
|
||||||
subtitle_langs: subs.langs,
|
subtitle_langs: subs.langs,
|
||||||
youtube_player_clients: player_clients_out.clone(),
|
youtube_player_clients: player_clients_out.clone(),
|
||||||
|
sponsorblock_mode: sponsorblock_out.clone(),
|
||||||
convert_mode: convert.mode.clone(),
|
convert_mode: convert.mode.clone(),
|
||||||
convert_crf: convert.crf,
|
convert_crf: convert.crf,
|
||||||
convert_preset: convert.preset.clone(),
|
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())
|
let time = c.get("_time_text").and_then(|v| v.as_str())
|
||||||
.or_else(|| 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);
|
.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!({
|
Some(serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"author": author,
|
"author": author,
|
||||||
|
|
@ -1839,6 +1852,8 @@ async fn get_comments(
|
||||||
"likes": likes,
|
"likes": likes,
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
"time": time,
|
"time": time,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"is_uploader": is_uploader,
|
||||||
}))
|
}))
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
|
|
@ -2528,6 +2543,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
);
|
);
|
||||||
downloader.subtitle_defaults = config.subtitles.clone();
|
downloader.subtitle_defaults = config.subtitles.clone();
|
||||||
downloader.youtube_player_clients = config.backup.youtube_player_clients.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();
|
downloader.convert_defaults = config.convert.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);
|
||||||
|
|
|
||||||
|
|
@ -1017,6 +1017,10 @@ async function openChannelOptions(idx){
|
||||||
<label>YouTube player clients (blank = global)</label>
|
<label>YouTube player clients (blank = global)</label>
|
||||||
<input type="text" id="opt-player-clients" value="${esc(opts.youtube_player_clients||'')}" placeholder="e.g. tv,mweb" 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-player-clients" value="${esc(opts.youtube_player_clients||'')}" placeholder="e.g. tv,mweb" 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-bottom:4px">Per-channel <code>--extractor-args youtube:player_client</code> override. If this channel keeps hitting captchas, try <code>tv,mweb</code> (least bot-checked).</div>
|
<div class="settings-hint" style="margin-bottom:4px">Per-channel <code>--extractor-args youtube:player_client</code> override. If this channel keeps hitting captchas, try <code>tv,mweb</code> (least bot-checked).</div>
|
||||||
|
<div class="settings-row"><label>SponsorBlock (blank = global)</label>
|
||||||
|
<select id="opt-sponsorblock" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
|
||||||
|
${[['','Default (global)'],['off','Off'],['mark','Mark chapters'],['remove','Remove segments']].map(([v,l])=>`<option value="${v}"${(opts.sponsorblock_mode||'')===v?' selected':''}>${l}</option>`).join('')}
|
||||||
|
</select></div>
|
||||||
<div class="settings-row"><label>Convert (blank = global)</label>
|
<div class="settings-row"><label>Convert (blank = global)</label>
|
||||||
<select id="opt-convert" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
|
<select id="opt-convert" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
|
||||||
${[['','Default (global)'],['off','Off'],['remux-mp4','Remux → mp4'],['h264-mp4','H.264 mp4'],['audio','Audio']].map(([v,l])=>`<option value="${v}"${(opts.convert_mode||'')===v?' selected':''}>${l}</option>`).join('')}
|
${[['','Default (global)'],['off','Off'],['remux-mp4','Remux → mp4'],['h264-mp4','H.264 mp4'],['audio','Audio']].map(([v,l])=>`<option value="${v}"${(opts.convert_mode||'')===v?' selected':''}>${l}</option>`).join('')}
|
||||||
|
|
@ -1056,6 +1060,7 @@ function readChannelOptionsForm(){
|
||||||
subtitles_embed:triValue('opt-subs-embed'),
|
subtitles_embed:triValue('opt-subs-embed'),
|
||||||
subtitle_format:strOrNull('opt-subs-format'),
|
subtitle_format:strOrNull('opt-subs-format'),
|
||||||
youtube_player_clients:strOrNull('opt-player-clients'),
|
youtube_player_clients:strOrNull('opt-player-clients'),
|
||||||
|
sponsorblock_mode:strOrNull('opt-sponsorblock'),
|
||||||
convert_mode:strOrNull('opt-convert'),
|
convert_mode:strOrNull('opt-convert'),
|
||||||
extra_args:extra,
|
extra_args:extra,
|
||||||
};
|
};
|
||||||
|
|
@ -1308,45 +1313,111 @@ async function loadChapters(id){
|
||||||
function jumpToChapter(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
|
function jumpToChapter(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
|
||||||
|
|
||||||
/* ── Comments viewer ───────────────────────────────────────────── */
|
/* ── Comments viewer ───────────────────────────────────────────── */
|
||||||
|
let cmt={vid:null,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0};
|
||||||
async function openComments(videoId){
|
async function openComments(videoId){
|
||||||
const bg=document.createElement('div');bg.className='modal-bg';
|
const bg=document.createElement('div');bg.className='modal-bg';
|
||||||
bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
||||||
bg.innerHTML=`<div class="modal" style="max-width:640px;width:100%">
|
bg.innerHTML=`<div class="modal" style="max-width:680px;width:100%">
|
||||||
<div class="modal-hdr"><h2>💬 Comments</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
<div class="modal-hdr"><h2>💬 Comments</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
||||||
<div id="comments-body" style="overflow-y:auto;max-height:75vh"><em style="color:var(--muted)">Loading…</em></div>
|
<div style="display:flex;gap:6px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);flex-wrap:wrap">
|
||||||
|
<input id="cmt-search" placeholder="Search comments…" oninput="cmt.q=this.value;renderComments()"
|
||||||
|
style="flex:1;min-width:120px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px">
|
||||||
|
<select id="cmt-sort" onchange="cmt.sort=this.value;renderComments()"
|
||||||
|
style="padding:4px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px">
|
||||||
|
<option value="threaded">Threaded</option>
|
||||||
|
<option value="top">Top</option>
|
||||||
|
<option value="new">Newest</option>
|
||||||
|
<option value="old">Oldest</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="cmtCollapseAll(true)" title="Collapse all replies">⊟</button>
|
||||||
|
<button onclick="cmtCollapseAll(false)" title="Expand all replies">⊞</button>
|
||||||
|
</div>
|
||||||
|
<div id="cmt-status" style="font-size:11px;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--border)"></div>
|
||||||
|
<div id="comments-body" style="overflow-y:auto;max-height:68vh"><em style="color:var(--muted)">Loading…</em></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(bg);
|
document.body.appendChild(bg);
|
||||||
|
cmt={vid:videoId,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0};
|
||||||
try{
|
try{
|
||||||
const r=await(await api('/api/comments/'+encodeURIComponent(videoId))).json();
|
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){
|
}catch(e){
|
||||||
document.getElementById('comments-body').innerHTML=`<div style="color:#f87171">Failed to load comments: ${esc(e.message)}</div>`;
|
const b=document.getElementById('comments-body');
|
||||||
|
if(b)b.innerHTML=`<div style="color:#f87171;padding:10px">Failed to load comments: ${esc(e.message)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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'),'<mark>$1</mark>')}catch{return e}
|
||||||
|
}
|
||||||
|
function renderComments(){
|
||||||
const body=document.getElementById('comments-body');if(!body)return;
|
const body=document.getElementById('comments-body');if(!body)return;
|
||||||
|
const status=document.getElementById('cmt-status');
|
||||||
|
const list=cmt.list;
|
||||||
if(!list.length){
|
if(!list.length){
|
||||||
body.innerHTML='<div style="color:var(--muted);padding:12px">No comments captured for this video. Enable "Fetch comments" in this channel\'s options + re-download.</div>';
|
body.innerHTML='<div style="color:var(--muted);padding:12px">No comments captured for this video. Enable "Fetch comments" in this channel\'s options + re-download.</div>';
|
||||||
|
if(status)status.textContent='';
|
||||||
return;
|
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 byId=new Map(list.map(c=>[c.id,{...c,children:[]}]));
|
||||||
const roots=[];
|
const roots=[];
|
||||||
for(const c of byId.values()){
|
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);
|
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 render=(c,depth)=>{
|
||||||
const indent=depth*16;
|
if(q&&!keep.has(c.id))return'';
|
||||||
const author=c.author?`<strong>${esc(c.author)}</strong>`:'<em>unknown</em>';
|
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?`<span onclick="cmtToggle('${c.id}')" style="cursor:pointer;user-select:none;color:var(--muted)">${collapsed?'▸':'▾'}${c.children.length}</span> `:'';
|
||||||
|
const author=c.author?`<strong>${cmtHl(c.author)}</strong>`:'<em>unknown</em>';
|
||||||
|
const op=c.is_uploader?' <span style="font-size:10px;background:#3b82f6;color:#fff;padding:0 4px;border-radius:3px">OP</span>':'';
|
||||||
const meta=[c.time,c.likes!=null?`${c.likes} likes`:null].filter(Boolean).join(' · ');
|
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(''):'';
|
const newBadge=isNew?' <span style="font-size:10px;color:#34d399">● new</span>':'';
|
||||||
return `<div style="padding:6px 10px 6px ${10+indent}px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4">
|
const childHtml=collapsed?'':c.children.map(r=>render(r,depth+1)).join('');
|
||||||
<div style="margin-bottom:2px">${author}${meta?` <span style="color:var(--muted);font-size:11px">· ${esc(meta)}</span>`:''}</div>
|
return `<div style="padding:6px 10px 6px ${10+indent}px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4;${isNew?'border-left:2px solid #34d399':''}">
|
||||||
<div style="white-space:pre-wrap;word-wrap:break-word">${esc(c.text)}</div>
|
<div style="margin-bottom:2px">${toggle}${author}${op}${meta?` <span style="color:var(--muted);font-size:11px">· ${esc(meta)}</span>`:''}${newBadge}</div>
|
||||||
</div>${replies}`;
|
<div style="white-space:pre-wrap;word-wrap:break-word">${cmtHl(c.text)}</div>
|
||||||
|
</div>${childHtml}`;
|
||||||
};
|
};
|
||||||
body.innerHTML=`<div style="font-size:11px;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--border)">${list.length} comment${list.length===1?'':'s'}</div>`+roots.map(c=>render(c,0)).join('');
|
const ordered=cmp?roots.slice().sort(cmp):roots;
|
||||||
|
body.innerHTML=ordered.map(c=>render(c,0)).join('')||'<div style="color:var(--muted);padding:12px">No comments match your search.</div>';
|
||||||
|
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(`<span style="color:#34d399">${newCount} new since last visit</span>`);
|
||||||
|
if(q)bits.push('filtered');
|
||||||
|
status.innerHTML=bits.join(' · ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Select / Details ───────────────────────────────────────────── */
|
/* ── Select / Details ───────────────────────────────────────────── */
|
||||||
|
|
@ -1542,6 +1613,13 @@ async function openSettings(){
|
||||||
<input type="text" id="cf-player-clients" value="${esc(cur.youtube_player_clients||'')}" placeholder="default" style="width:140px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
|
<input type="text" id="cf-player-clients" value="${esc(cur.youtube_player_clients||'')}" placeholder="default" style="width:140px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-hint" style="margin-bottom:6px">Comma-separated <code>--extractor-args youtube:player_client</code>. Blank = let yt-dlp pick (recommended). YouTube's bot-detection targets different clients over time; if you keep hitting captchas, try <code>tv,mweb</code> — currently the least-checked. Per-channel overrides live in each channel's options.</div>
|
<div class="settings-hint" style="margin-bottom:6px">Comma-separated <code>--extractor-args youtube:player_client</code>. Blank = let yt-dlp pick (recommended). YouTube's bot-detection targets different clients over time; if you keep hitting captchas, try <code>tv,mweb</code> — currently the least-checked. Per-channel overrides live in each channel's options.</div>
|
||||||
|
<div class="settings-row" style="margin-top:8px">
|
||||||
|
<label for="cf-sponsorblock">SponsorBlock</label>
|
||||||
|
<select id="cf-sponsorblock" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
|
||||||
|
${[['off','Off'],['mark','Mark chapters'],['remove','Remove segments']].map(([v,l])=>`<option value="${v}"${(cur.sponsorblock_mode||'mark')===v?' selected':''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-hint" style="margin-bottom:6px">Uses the community SponsorBlock database for sponsor / intro / self-promo segments. <b>Mark</b> adds skippable chapter markers; <b>Remove</b> cuts the segments out of the saved file. Per-channel overrides live in each channel's options.</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 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-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div>
|
||||||
|
|
@ -1783,12 +1861,13 @@ async function saveSettings(btn){
|
||||||
const subsLangs=document.getElementById('cf-subs-langs')?.value||'';
|
const subsLangs=document.getElementById('cf-subs-langs')?.value||'';
|
||||||
const subsFormat=document.getElementById('cf-subs-format')?.value||'';
|
const subsFormat=document.getElementById('cf-subs-format')?.value||'';
|
||||||
const playerClients=document.getElementById('cf-player-clients')?.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 convMode=document.getElementById('cf-convert-mode')?.value||'';
|
||||||
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
|
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
|
||||||
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
|
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
|
||||||
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
|
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
|
||||||
const convKeep=document.getElementById('cf-convert-keep')?.checked||false;
|
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(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;
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ fn settings_roundtrip_and_persist() {
|
||||||
let (code, body) = s.get("/api/settings");
|
let (code, body) = s.get("/api/settings");
|
||||||
assert_eq!(code, 200);
|
assert_eq!(code, 200);
|
||||||
assert_eq!(field(&body, "convert_mode"), Some(""), "default convert off");
|
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.
|
// Flip a few global settings.
|
||||||
let (code, _) = s.post("/api/settings", r#"{
|
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,
|
"max_concurrent":5,"use_bundled_ytdlp":false,"use_pot_provider":false,
|
||||||
"subtitles_enabled":true,"subtitles_auto":false,"subtitles_embed":true,
|
"subtitles_enabled":true,"subtitles_auto":false,"subtitles_embed":true,
|
||||||
"subtitle_langs":"en","subtitle_format":"srt","youtube_player_clients":"tv,mweb",
|
"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_mode":"h264-mp4","convert_crf":28,"convert_preset":"fast",
|
||||||
"convert_audio_format":"","convert_keep_original":true
|
"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_mode"), Some("h264-mp4"));
|
||||||
assert_eq!(field(&body, "convert_crf"), Some("28"));
|
assert_eq!(field(&body, "convert_crf"), Some("28"));
|
||||||
assert_eq!(field(&body, "youtube_player_clients"), Some("tv,mweb"));
|
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"));
|
assert_eq!(field(&body, "subtitle_format"), Some("srt"));
|
||||||
|
|
||||||
// …and so does config.toml on disk.
|
// …and so does config.toml on disk.
|
||||||
let cfg = std::fs::read_to_string(s.dir.join("config.toml")).unwrap();
|
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("mode = \"h264-mp4\""), "config persisted convert mode:\n{cfg}");
|
||||||
assert!(cfg.contains("youtube_player_clients = \"tv,mweb\""), "config persisted clients");
|
assert!(cfg.contains("youtube_player_clients = \"tv,mweb\""), "config persisted clients");
|
||||||
|
assert!(cfg.contains("sponsorblock_mode = \"remove\""), "config persisted sponsorblock");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -288,12 +292,13 @@ fn channel_options_roundtrip_and_clear() {
|
||||||
// Store an override.
|
// Store an override.
|
||||||
let (code, _) = s.post(
|
let (code, _) = s.post(
|
||||||
"/api/channels/channels/SomeChannel/options",
|
"/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);
|
assert_eq!(code, 200);
|
||||||
let (_, opts) = s.get("/api/channels/channels/SomeChannel/options");
|
let (_, opts) = s.get("/api/channels/channels/SomeChannel/options");
|
||||||
assert_eq!(field(&opts, "convert_mode"), Some("audio"));
|
assert_eq!(field(&opts, "convert_mode"), Some("audio"));
|
||||||
assert_eq!(field(&opts, "youtube_player_clients"), Some("tv"));
|
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.
|
// An all-default body hits the is_empty() delete path → back to defaults.
|
||||||
let (code, _) = s.post("/api/channels/channels/SomeChannel/options", r#"{}"#);
|
let (code, _) = s.post("/api/channels/channels/SomeChannel/options", r#"{}"#);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue