web/desktop: grid + subtitle fixes, comment toggle, faster startup
Several related UI and startup fixes from one session: - Web grid: channel and music views rendered in a single column because their content was wrapped in one nested div that became a single cell of the outer CSS grid. Span the wrappers (and .empty) across all columns with grid-column:1/-1. - Subtitles: route every track through /api/sub-vtt and strip per-cue position/alignment settings (align:start position:0% etc.) from both SRT and VTT, so auto-generated captions render centered instead of left- aligned. New strip_cue_settings/normalize_vtt + sub_vtt_url helpers. - Comment downloads: add a global backup.fetch_comments toggle (the per-channel option already existed; it's now a tri-state override that defers to the global). Full five-touchpoint wiring across config, download_options, the downloader's apply_comments resolver, both UIs' global + per-channel controls, and downloader seeding. - Desktop startup: run the initial library scan + search-index sync on a background thread (mirrors the web server's deferred bind) so the window appears immediately instead of blocking on a cold-cache scan; update() swaps the library in when it lands. - Perceptual dedup: cap worker threads via fingerprint::default_workers() (~half the cores, always leaving at least one free) instead of using every core, so the hashing pass doesn't starve the UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fdc7493eb9
commit
aed577ea2f
7 changed files with 245 additions and 50 deletions
81
src/app.rs
81
src/app.rs
|
|
@ -116,6 +116,11 @@ pub struct App {
|
||||||
/// non-YouTube content is included.
|
/// non-YouTube content is included.
|
||||||
library_root: PathBuf,
|
library_root: PathBuf,
|
||||||
library: Vec<library::Channel>,
|
library: Vec<library::Channel>,
|
||||||
|
/// Receiver for the initial library scan, which runs on a background
|
||||||
|
/// thread so the window appears immediately instead of blocking on a
|
||||||
|
/// cold-cache scan of a large library. `None` once the scan has landed
|
||||||
|
/// (or was never deferred). Polled in `update()`.
|
||||||
|
library_load_rx: Option<std::sync::mpsc::Receiver<Vec<library::Channel>>>,
|
||||||
sidebar_view: SidebarView,
|
sidebar_view: SidebarView,
|
||||||
selected_video: Option<String>,
|
selected_video: Option<String>,
|
||||||
/// Edit buffer for the selected video's note, plus the id it belongs
|
/// Edit buffer for the selected video's note, plus the id it belongs
|
||||||
|
|
@ -256,7 +261,9 @@ pub struct App {
|
||||||
struct ChannelOptionsForm {
|
struct ChannelOptionsForm {
|
||||||
quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p
|
quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p
|
||||||
audio_only: bool,
|
audio_only: bool,
|
||||||
fetch_comments: bool,
|
// Per-channel comment-fetch override: 0=Default(global), 1=On, 2=Off.
|
||||||
|
// Maps to Option<bool> on save (same tri-state as the subtitle indices).
|
||||||
|
comments_idx: usize,
|
||||||
skip_auth_check: bool,
|
skip_auth_check: bool,
|
||||||
limit_rate_kb: String,
|
limit_rate_kb: String,
|
||||||
min_filesize_mb: String,
|
min_filesize_mb: String,
|
||||||
|
|
@ -369,14 +376,25 @@ impl App {
|
||||||
let db_path = channels_root.join("yt-offline.db");
|
let db_path = channels_root.join("yt-offline.db");
|
||||||
let db = Database::open(&db_path)
|
let db = Database::open(&db_path)
|
||||||
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
|
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
|
||||||
|
// Defer the (potentially multi-minute, disk-bound) library scan +
|
||||||
|
// search-index sync to a background thread so the window appears
|
||||||
|
// immediately instead of blocking on a cold-cache scan of a large
|
||||||
|
// library. The UI renders an empty library until the scan lands and
|
||||||
|
// update() swaps it in. The folder/watched/flag reads below are cheap
|
||||||
|
// DB lookups that don't depend on the scan, so they stay synchronous.
|
||||||
|
let (library_load_tx, library_load_rx) =
|
||||||
|
std::sync::mpsc::channel::<Vec<library::Channel>>();
|
||||||
|
let status = "Scanning library…".to_string();
|
||||||
|
{
|
||||||
|
let db = db.clone();
|
||||||
|
let channels_root = channels_root.clone();
|
||||||
|
let ctx = cc.egui_ctx.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("yt-offline-libscan".into())
|
||||||
|
.spawn(move || {
|
||||||
let mut library = library::scan_channels_with_cache(&channels_root, Some(&db));
|
let mut library = library::scan_channels_with_cache(&channels_root, Some(&db));
|
||||||
let status = format!(
|
// Hydrate per-channel download options + folder assignments
|
||||||
"{} channels, {} videos",
|
// from SQLite onto the scanned library before publishing it.
|
||||||
library.len(),
|
|
||||||
library.iter().map(|c| c.total_videos()).sum::<usize>()
|
|
||||||
);
|
|
||||||
// Hydrate per-channel download options + folder assignments from
|
|
||||||
// SQLite onto the scanned library before publishing it to the UI.
|
|
||||||
if let Ok(map) = db.get_all_channel_options() {
|
if let Ok(map) = db.get_all_channel_options() {
|
||||||
library::apply_channel_options(&mut library, &map);
|
library::apply_channel_options(&mut library, &map);
|
||||||
}
|
}
|
||||||
|
|
@ -386,6 +404,12 @@ impl App {
|
||||||
if let Err(e) = db.sync_search_index(&library::build_search_entries(&library)) {
|
if let Err(e) = db.sync_search_index(&library::build_search_entries(&library)) {
|
||||||
eprintln!("search index sync failed: {e}");
|
eprintln!("search index sync failed: {e}");
|
||||||
}
|
}
|
||||||
|
let _ = library_load_tx.send(library);
|
||||||
|
ctx.request_repaint(); // wake update() to swap the result in
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let library: Vec<library::Channel> = Vec::new();
|
||||||
let folders = db.list_folders().unwrap_or_default();
|
let folders = db.list_folders().unwrap_or_default();
|
||||||
let watched = db.get_watched().unwrap_or_default();
|
let watched = db.get_watched().unwrap_or_default();
|
||||||
let flags = db.get_video_flags().unwrap_or_default();
|
let flags = db.get_video_flags().unwrap_or_default();
|
||||||
|
|
@ -408,6 +432,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.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
|
||||||
|
downloader.fetch_comments = config.backup.fetch_comments;
|
||||||
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();
|
||||||
|
|
@ -473,6 +498,7 @@ impl App {
|
||||||
channels_root: channels_root.clone(),
|
channels_root: channels_root.clone(),
|
||||||
library_root,
|
library_root,
|
||||||
library,
|
library,
|
||||||
|
library_load_rx: Some(library_load_rx),
|
||||||
sidebar_view: SidebarView::All,
|
sidebar_view: SidebarView::All,
|
||||||
selected_video: None,
|
selected_video: None,
|
||||||
note_buffer: String::new(),
|
note_buffer: String::new(),
|
||||||
|
|
@ -575,7 +601,7 @@ impl App {
|
||||||
ChannelOptionsForm {
|
ChannelOptionsForm {
|
||||||
quality_idx,
|
quality_idx,
|
||||||
audio_only: opts.audio_only,
|
audio_only: opts.audio_only,
|
||||||
fetch_comments: opts.fetch_comments,
|
comments_idx: tri_to_idx(opts.fetch_comments),
|
||||||
skip_auth_check: opts.skip_auth_check,
|
skip_auth_check: opts.skip_auth_check,
|
||||||
limit_rate_kb: num(opts.limit_rate_kb),
|
limit_rate_kb: num(opts.limit_rate_kb),
|
||||||
min_filesize_mb: num(opts.min_filesize_mb),
|
min_filesize_mb: num(opts.min_filesize_mb),
|
||||||
|
|
@ -613,7 +639,7 @@ impl App {
|
||||||
crate::download_options::DownloadOptions {
|
crate::download_options::DownloadOptions {
|
||||||
quality,
|
quality,
|
||||||
audio_only: f.audio_only,
|
audio_only: f.audio_only,
|
||||||
fetch_comments: f.fetch_comments,
|
fetch_comments: idx_to_tri(f.comments_idx),
|
||||||
skip_auth_check: f.skip_auth_check,
|
skip_auth_check: f.skip_auth_check,
|
||||||
limit_rate_kb: parse_num(&f.limit_rate_kb),
|
limit_rate_kb: parse_num(&f.limit_rate_kb),
|
||||||
min_filesize_mb: parse_num(&f.min_filesize_mb),
|
min_filesize_mb: parse_num(&f.min_filesize_mb),
|
||||||
|
|
@ -946,7 +972,7 @@ impl App {
|
||||||
let progress = self.dedup_progress.clone();
|
let progress = self.dedup_progress.clone();
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
self.dedup_rx = Some(rx);
|
self.dedup_rx = Some(rx);
|
||||||
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
|
let workers = crate::fingerprint::default_workers();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let res = crate::fingerprint::rebuild_and_group(
|
let res = crate::fingerprint::rebuild_and_group(
|
||||||
&db, inputs, &valid_paths, workers, &progress.0, &progress.1,
|
&db, inputs, &valid_paths, workers, &progress.0, &progress.1,
|
||||||
|
|
@ -2038,6 +2064,19 @@ impl App {
|
||||||
fn maintenance_screen(&mut self, ctx: &egui::Context) {
|
fn maintenance_screen(&mut self, ctx: &egui::Context) {
|
||||||
let report = self.health_report.clone().unwrap_or_default();
|
let report = self.health_report.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// Swap in the library once the deferred startup scan finishes.
|
||||||
|
let loaded_library = self.library_load_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||||
|
if let Some(lib) = loaded_library {
|
||||||
|
self.library = lib;
|
||||||
|
self.status = format!(
|
||||||
|
"{} channels, {} videos",
|
||||||
|
self.library.len(),
|
||||||
|
self.library.iter().map(|c| c.total_videos()).sum::<usize>()
|
||||||
|
);
|
||||||
|
self.library_load_rx = None;
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
// Drain the background dedup result if it's ready.
|
// Drain the background dedup result if it's ready.
|
||||||
let mut dedup_done = None;
|
let mut dedup_done = None;
|
||||||
if let Some(rx) = &self.dedup_rx {
|
if let Some(rx) = &self.dedup_rx {
|
||||||
|
|
@ -2387,8 +2426,14 @@ impl App {
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
ui.label("Fetch comments");
|
ui.label("Fetch comments");
|
||||||
ui.checkbox(&mut form.fetch_comments, "Embed --write-comments into info.json")
|
egui::ComboBox::from_id_salt("ch_comments")
|
||||||
.on_hover_text("Slow on popular videos. Once captured, comments are browsable from the player modal in the web UI.");
|
.selected_text(match form.comments_idx { 1 => "On", 2 => "Off", _ => "Default (global)" })
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut form.comments_idx, 0, "Default (global)");
|
||||||
|
ui.selectable_value(&mut form.comments_idx, 1, "On");
|
||||||
|
ui.selectable_value(&mut form.comments_idx, 2, "Off");
|
||||||
|
})
|
||||||
|
.response.on_hover_text("Default = use the global Fetch comments setting. Adds --write-comments — slow on popular videos. Once captured, comments are browsable from the player modal in the web UI.");
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
ui.label("Skip auth check");
|
ui.label("Skip auth check");
|
||||||
|
|
@ -3020,6 +3065,15 @@ impl App {
|
||||||
overrides live in each channel's options.");
|
overrides live in each channel's options.");
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Fetch comments:");
|
||||||
|
ui.checkbox(&mut self.config.backup.fetch_comments, "Download comments (--write-comments)")
|
||||||
|
.on_hover_text(
|
||||||
|
"Fetch each video's comment tree into its info.json so the \
|
||||||
|
web player's Comments tab is populated. Slow on popular \
|
||||||
|
videos (yt-dlp paginates through thousands of replies). \
|
||||||
|
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)
|
||||||
|
|
@ -3429,6 +3483,7 @@ impl App {
|
||||||
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.sponsorblock_mode = self.config.backup.sponsorblock_mode.clone();
|
||||||
|
self.downloader.fetch_comments = self.config.backup.fetch_comments;
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,13 @@ pub struct BackupSection {
|
||||||
/// overrides live in [`crate::download_options::DownloadOptions`].
|
/// overrides live in [`crate::download_options::DownloadOptions`].
|
||||||
#[serde(default = "default_sponsorblock_mode")]
|
#[serde(default = "default_sponsorblock_mode")]
|
||||||
pub sponsorblock_mode: String,
|
pub sponsorblock_mode: String,
|
||||||
|
/// Download the comment tree into each video's info.json sidecar via
|
||||||
|
/// yt-dlp `--write-comments`. Off by default — comment download is slow
|
||||||
|
/// (yt-dlp paginates through thousands of replies on popular videos).
|
||||||
|
/// When on, the player's Comments tab is populated. Per-channel overrides
|
||||||
|
/// live in [`crate::download_options::DownloadOptions`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub fetch_comments: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_concurrent() -> usize { 3 }
|
fn default_max_concurrent() -> usize { 3 }
|
||||||
|
|
@ -273,6 +280,7 @@ impl Config {
|
||||||
use_pot_provider: false,
|
use_pot_provider: false,
|
||||||
youtube_player_clients: String::new(),
|
youtube_player_clients: String::new(),
|
||||||
sponsorblock_mode: default_sponsorblock_mode(),
|
sponsorblock_mode: default_sponsorblock_mode(),
|
||||||
|
fetch_comments: false,
|
||||||
},
|
},
|
||||||
player: PlayerSection::default(),
|
player: PlayerSection::default(),
|
||||||
ui: UiSection::default(),
|
ui: UiSection::default(),
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,14 @@ pub struct DownloadOptions {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extra_args: Vec<String>,
|
pub extra_args: Vec<String>,
|
||||||
|
|
||||||
/// Embed video comments into the info.json sidecar (yt-dlp
|
/// Per-channel override for fetching video comments into the info.json
|
||||||
/// `--write-comments`). Off by default because comment download is slow
|
/// sidecar (yt-dlp `--write-comments`). `None` defers to the global
|
||||||
/// — yt-dlp paginates through thousands of replies for popular videos.
|
/// `backup.fetch_comments`; `Some(true)`/`Some(false)` forces it on/off
|
||||||
/// When on, the web UI's player modal exposes a Comments tab.
|
/// for this channel. Resolved in the downloader's comment resolver, which
|
||||||
|
/// merges this with the global default. When on, the player's Comments
|
||||||
|
/// tab is populated (comment download is slow on popular videos).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub fetch_comments: bool,
|
pub fetch_comments: Option<bool>,
|
||||||
|
|
||||||
/// Skip yt-dlp's channel-tab authentication sanity check by passing
|
/// Skip yt-dlp's channel-tab authentication sanity check by passing
|
||||||
/// `--extractor-args youtubetab:skip=authcheck`.
|
/// `--extractor-args youtubetab:skip=authcheck`.
|
||||||
|
|
@ -179,9 +181,9 @@ impl DownloadOptions {
|
||||||
cmd.arg(arg);
|
cmd.arg(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.fetch_comments {
|
// Comment fetching (--write-comments) is emitted by the downloader's
|
||||||
cmd.arg("--write-comments");
|
// comment resolver, which merges this per-channel override with the
|
||||||
}
|
// global backup.fetch_comments default. apply() handles the rest.
|
||||||
if self.skip_auth_check {
|
if self.skip_auth_check {
|
||||||
// Its own --extractor-args flag; the youtubetab: namespace is
|
// Its own --extractor-args flag; the youtubetab: namespace is
|
||||||
// distinct from the POT provider's youtubepot-bgutilhttp: one,
|
// distinct from the POT provider's youtubepot-bgutilhttp: one,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
//! | `--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/-remove all` | SponsorBlock handling, per `sponsorblock_mode` (off/mark/remove; see [`Self::apply_sponsorblock`]) |
|
//! | `--sponsorblock-mark/-remove all` | SponsorBlock handling, per `sponsorblock_mode` (off/mark/remove; see [`Self::apply_sponsorblock`]) |
|
||||||
|
//! | `--write-comments` | Fetch the comment tree, per `fetch_comments` global + per-channel override (see [`Self::apply_comments`]) |
|
||||||
//! | `--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 |
|
||||||
|
|
@ -317,6 +318,10 @@ pub struct Downloader {
|
||||||
/// Global `backup.sponsorblock_mode` ("off" / "mark" / "remove").
|
/// Global `backup.sponsorblock_mode` ("off" / "mark" / "remove").
|
||||||
/// Per-channel options can override. Set at construction + on save.
|
/// Per-channel options can override. Set at construction + on save.
|
||||||
pub sponsorblock_mode: String,
|
pub sponsorblock_mode: String,
|
||||||
|
/// Global `backup.fetch_comments`. When true, downloads pass
|
||||||
|
/// `--write-comments`. Per-channel options can override. Set at
|
||||||
|
/// construction + on settings save.
|
||||||
|
pub fetch_comments: bool,
|
||||||
/// 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,
|
||||||
|
|
@ -363,6 +368,7 @@ impl Downloader {
|
||||||
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(),
|
sponsorblock_mode: "mark".to_string(),
|
||||||
|
fetch_comments: false,
|
||||||
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,
|
||||||
|
|
@ -411,6 +417,18 @@ impl Downloader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add `--write-comments` when comment fetching is enabled, merging the
|
||||||
|
/// per-channel override (`Some(true)`/`Some(false)`) with the global
|
||||||
|
/// `fetch_comments` default (used when the override is `None`).
|
||||||
|
fn apply_comments(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
|
||||||
|
let enabled = opts
|
||||||
|
.and_then(|o| o.fetch_comments)
|
||||||
|
.unwrap_or(self.fetch_comments);
|
||||||
|
if enabled {
|
||||||
|
cmd.arg("--write-comments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -763,6 +781,8 @@ impl Downloader {
|
||||||
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).
|
// SponsorBlock: global default + per-channel override (off/mark/remove).
|
||||||
self.apply_sponsorblock(&mut cmd, channel_options);
|
self.apply_sponsorblock(&mut cmd, channel_options);
|
||||||
|
// Comment fetching: global backup.fetch_comments + per-channel override.
|
||||||
|
self.apply_comments(&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.
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,18 @@ pub struct FpComputed {
|
||||||
pub hashes: Vec<u64>,
|
pub hashes: Vec<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many worker threads the dedup/fingerprint pass should use by default.
|
||||||
|
/// Deliberately leaves CPU headroom — each worker also spawns an ffmpeg
|
||||||
|
/// process for frame extraction, and the pass is a background maintenance job
|
||||||
|
/// that must not starve the UI, downloads, or scans. Uses roughly half the
|
||||||
|
/// cores, clamped to `[1, cores-1]` so at least one core stays free.
|
||||||
|
pub fn default_workers() -> usize {
|
||||||
|
let cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(4);
|
||||||
|
(cores / 2).clamp(1, cores.saturating_sub(1).max(1))
|
||||||
|
}
|
||||||
|
|
||||||
/// Fingerprint many videos in parallel, bumping `progress` after each one so a
|
/// Fingerprint many videos in parallel, bumping `progress` after each one so a
|
||||||
/// UI can show "N / total". Mirrors the library scanner's hand-rolled worker
|
/// UI can show "N / total". Mirrors the library scanner's hand-rolled worker
|
||||||
/// pool (no rayon dependency).
|
/// pool (no rayon dependency).
|
||||||
|
|
@ -312,6 +324,17 @@ pub fn rebuild_and_group(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_workers_leaves_headroom() {
|
||||||
|
let w = default_workers();
|
||||||
|
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
|
||||||
|
assert!(w >= 1, "must use at least one worker");
|
||||||
|
// On any multi-core machine at least one core stays free.
|
||||||
|
if cores > 1 {
|
||||||
|
assert!(w < cores, "should leave a core free (w={w}, cores={cores})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dhash_gradients() {
|
fn dhash_gradients() {
|
||||||
// Strictly increasing rows → every pixel < its right neighbour → no
|
// Strictly increasing rows → every pixel < its right neighbour → no
|
||||||
|
|
|
||||||
105
src/web.rs
105
src/web.rs
|
|
@ -416,6 +416,10 @@ struct SettingsPayload {
|
||||||
/// SponsorBlock mode: "off" / "mark" / "remove".
|
/// SponsorBlock mode: "off" / "mark" / "remove".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
sponsorblock_mode: String,
|
sponsorblock_mode: String,
|
||||||
|
/// Global comment fetching (`--write-comments`). Per-channel overrides
|
||||||
|
/// live in each channel's DownloadOptions, not here.
|
||||||
|
#[serde(default)]
|
||||||
|
fetch_comments: bool,
|
||||||
/// 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,
|
||||||
|
|
@ -453,6 +457,22 @@ fn file_url(library_root: &StdPath, full: &StdPath) -> Option<String> {
|
||||||
Some(format!("/files/{}", parts.join("/")))
|
Some(format!("/files/{}", parts.join("/")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `file_url` but targets the `/api/sub-vtt/…` endpoint. All subtitle
|
||||||
|
/// tracks are routed through that handler (not the raw `/files/` mount) so
|
||||||
|
/// their cue position/alignment settings get stripped before the player
|
||||||
|
/// renders them — otherwise auto-generated captions render left-aligned.
|
||||||
|
fn sub_vtt_url(library_root: &StdPath, full: &StdPath) -> Option<String> {
|
||||||
|
let rel = full.strip_prefix(library_root).ok()?;
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for c in rel.components() {
|
||||||
|
if let std::path::Component::Normal(s) = c {
|
||||||
|
parts.push(percent_encode_segment(s.to_str()?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() { return None; }
|
||||||
|
Some(format!("/api/sub-vtt/{}", parts.join("/")))
|
||||||
|
}
|
||||||
|
|
||||||
fn percent_encode_segment(s: &str) -> String {
|
fn percent_encode_segment(s: &str) -> String {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
|
|
@ -603,7 +623,42 @@ fn srt_to_vtt(srt: &str) -> String {
|
||||||
let mut out = String::from("WEBVTT\n\n");
|
let mut out = String::from("WEBVTT\n\n");
|
||||||
for line in srt.lines() {
|
for line in srt.lines() {
|
||||||
if line.contains("-->") {
|
if line.contains("-->") {
|
||||||
out.push_str(&line.replace(',', "."));
|
out.push_str(&strip_cue_settings(&line.replace(',', ".")));
|
||||||
|
} else {
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the per-cue position/alignment settings ("align:start position:0%",
|
||||||
|
/// "line:…", "size:…") that yt-dlp's auto-generated captions append to every
|
||||||
|
/// cue timing line. Browsers honor them and left-align/offset the text;
|
||||||
|
/// removing them restores the default centered, bottom rendering. Only the
|
||||||
|
/// settings *after* the "start --> end" timestamps are removed — the two
|
||||||
|
/// timestamps are preserved verbatim.
|
||||||
|
fn strip_cue_settings(timing_line: &str) -> String {
|
||||||
|
match timing_line.find("-->") {
|
||||||
|
Some(arrow) => {
|
||||||
|
let start = timing_line[..arrow].trim_end();
|
||||||
|
let after = timing_line[arrow + 3..].trim_start();
|
||||||
|
// The end timestamp is the first whitespace-delimited token; any
|
||||||
|
// cue settings follow it and are dropped.
|
||||||
|
let end = after.split_whitespace().next().unwrap_or("");
|
||||||
|
format!("{start} --> {end}")
|
||||||
|
}
|
||||||
|
None => timing_line.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip cue settings from an already-WebVTT document (leaves the header,
|
||||||
|
/// cue identifiers, and text untouched — only timing lines are normalized).
|
||||||
|
fn normalize_vtt(vtt: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(vtt.len());
|
||||||
|
for line in vtt.lines() {
|
||||||
|
if line.contains("-->") {
|
||||||
|
out.push_str(&strip_cue_settings(line));
|
||||||
} else {
|
} else {
|
||||||
out.push_str(line);
|
out.push_str(line);
|
||||||
}
|
}
|
||||||
|
|
@ -817,6 +872,27 @@ mod tests {
|
||||||
assert!(vtt.contains("Hello, world"));
|
assert!(vtt.contains("Hello, world"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_cue_settings_drops_alignment_keeps_timestamps() {
|
||||||
|
let line = "00:00:01.000 --> 00:00:03.000 align:start position:0%";
|
||||||
|
assert_eq!(strip_cue_settings(line), "00:00:01.000 --> 00:00:03.000");
|
||||||
|
// A plain timing line is unchanged.
|
||||||
|
let plain = "00:00:01.000 --> 00:00:03.000";
|
||||||
|
assert_eq!(strip_cue_settings(plain), plain);
|
||||||
|
// Non-timing lines pass through untouched.
|
||||||
|
assert_eq!(strip_cue_settings("Hello align:start"), "Hello align:start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_vtt_strips_settings_only_on_timing_lines() {
|
||||||
|
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 align:start position:10%\nHello\n";
|
||||||
|
let out = normalize_vtt(vtt);
|
||||||
|
assert!(out.contains("00:00:01.000 --> 00:00:03.000\n"));
|
||||||
|
assert!(!out.contains("align:start"));
|
||||||
|
assert!(out.contains("Hello"));
|
||||||
|
assert!(out.starts_with("WEBVTT"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn count_cookies_only_counts_seven_field_lines() {
|
fn count_cookies_only_counts_seven_field_lines() {
|
||||||
let body = "# Netscape HTTP Cookie File\n\
|
let body = "# Netscape HTTP Cookie File\n\
|
||||||
|
|
@ -1214,14 +1290,9 @@ async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
|
||||||
let thumb_url = v.thumb_path.as_deref().and_then(|p| file_url(root, p));
|
let thumb_url = v.thumb_path.as_deref().and_then(|p| file_url(root, p));
|
||||||
let subtitles: Vec<SubtitleInfo> = v.subtitles.iter()
|
let subtitles: Vec<SubtitleInfo> = v.subtitles.iter()
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
let is_srt = s.path.extension().and_then(|e| e.to_str()) == Some("srt");
|
// Route every track (SRT and VTT alike) through /api/sub-vtt so
|
||||||
let url = if is_srt {
|
// cue settings are stripped and SRT is converted on the fly.
|
||||||
// Route SRT through the on-the-fly conversion endpoint.
|
let url = sub_vtt_url(root, &s.path)?;
|
||||||
let rel = s.path.strip_prefix(root).ok()?;
|
|
||||||
Some(format!("/api/sub-vtt/{}", rel.display()))
|
|
||||||
} else {
|
|
||||||
file_url(root, &s.path)
|
|
||||||
}?;
|
|
||||||
Some(SubtitleInfo {
|
Some(SubtitleInfo {
|
||||||
lang: s.lang.clone(),
|
lang: s.lang.clone(),
|
||||||
label: lang_label(&s.lang),
|
label: lang_label(&s.lang),
|
||||||
|
|
@ -1444,6 +1515,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
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 sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
||||||
|
let fetch_comments_out = cfg.backup.fetch_comments;
|
||||||
let convert = cfg.convert.clone();
|
let convert = cfg.convert.clone();
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
|
|
@ -1483,6 +1555,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
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(),
|
sponsorblock_mode: sponsorblock_out.clone(),
|
||||||
|
fetch_comments: fetch_comments_out,
|
||||||
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(),
|
||||||
|
|
@ -1529,6 +1602,7 @@ async fn post_settings(
|
||||||
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();
|
cfg.backup.sponsorblock_mode = body.sponsorblock_mode.trim().to_string();
|
||||||
|
cfg.backup.fetch_comments = body.fetch_comments;
|
||||||
// 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;
|
||||||
|
|
@ -1551,6 +1625,7 @@ async fn post_settings(
|
||||||
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 sponsorblock_out = cfg.backup.sponsorblock_mode.clone();
|
||||||
|
let fetch_comments_out = cfg.backup.fetch_comments;
|
||||||
let convert = cfg.convert.clone();
|
let convert = cfg.convert.clone();
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
|
|
@ -1565,6 +1640,7 @@ async fn post_settings(
|
||||||
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.sponsorblock_mode = sponsorblock_out.clone();
|
||||||
|
dl.fetch_comments = fetch_comments_out;
|
||||||
dl.convert_defaults = convert.clone();
|
dl.convert_defaults = convert.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1622,6 +1698,7 @@ async fn post_settings(
|
||||||
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(),
|
sponsorblock_mode: sponsorblock_out.clone(),
|
||||||
|
fetch_comments: fetch_comments_out,
|
||||||
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(),
|
||||||
|
|
@ -1630,7 +1707,9 @@ async fn post_settings(
|
||||||
}).into_response()
|
}).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/sub-vtt/*path` — serve an SRT subtitle file as WebVTT.
|
/// `GET /api/sub-vtt/*path` — serve a subtitle file as WebVTT: SRT is
|
||||||
|
/// converted on the fly, VTT is normalized; both have per-cue position/
|
||||||
|
/// alignment settings stripped so captions render centered, not left-aligned.
|
||||||
///
|
///
|
||||||
/// The path is relative to the channels root. The file must be within the
|
/// The path is relative to the channels root. The file must be within the
|
||||||
/// channels root (path traversal is rejected with 403).
|
/// channels root (path traversal is rejected with 403).
|
||||||
|
|
@ -1651,7 +1730,8 @@ async fn get_sub_vtt(
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
};
|
};
|
||||||
let vtt = srt_to_vtt(&content);
|
let is_srt = path.extension().and_then(|e| e.to_str()) == Some("srt");
|
||||||
|
let vtt = if is_srt { srt_to_vtt(&content) } else { normalize_vtt(&content) };
|
||||||
([(header::CONTENT_TYPE, "text/vtt; charset=utf-8")], vtt).into_response()
|
([(header::CONTENT_TYPE, "text/vtt; charset=utf-8")], vtt).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2226,7 +2306,7 @@ fn run_dedup(
|
||||||
by_path: HashMap<String, SimilarVideo>,
|
by_path: HashMap<String, SimilarVideo>,
|
||||||
valid_paths: HashSet<String>,
|
valid_paths: HashSet<String>,
|
||||||
) {
|
) {
|
||||||
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
|
let workers = crate::fingerprint::default_workers();
|
||||||
let outcome: Result<Vec<SimilarGroup>, String> =
|
let outcome: Result<Vec<SimilarGroup>, String> =
|
||||||
crate::fingerprint::rebuild_and_group(&db, inputs, &valid_paths, workers, &dedup.done, &dedup.total)
|
crate::fingerprint::rebuild_and_group(&db, inputs, &valid_paths, workers, &dedup.done, &dedup.total)
|
||||||
.map(|path_groups| {
|
.map(|path_groups| {
|
||||||
|
|
@ -2853,6 +2933,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.sponsorblock_mode = config.backup.sponsorblock_mode.clone();
|
||||||
|
downloader.fetch_comments = config.backup.fetch_comments;
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@
|
||||||
.dl-new-flags{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
|
.dl-new-flags{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
|
||||||
.dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}
|
.dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}
|
||||||
.preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
|
.preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
|
||||||
.empty{text-align:center;color:var(--muted);padding:40px;font-size:13px}
|
.empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px;font-size:13px}
|
||||||
@media(max-width:640px){
|
@media(max-width:640px){
|
||||||
#menu-btn{display:block}
|
#menu-btn{display:block}
|
||||||
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
|
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
|
||||||
|
|
@ -776,7 +776,7 @@ function renderChannelGrid(){
|
||||||
const chs=q?library.filter(ch=>ch.name.toLowerCase().includes(q)||ch.uploader?.toLowerCase().includes(q)):library;
|
const chs=q?library.filter(ch=>ch.name.toLowerCase().includes(q)||ch.uploader?.toLowerCase().includes(q)):library;
|
||||||
setStatus(chs.length+' channel'+(chs.length!==1?'s':''));
|
setStatus(chs.length+' channel'+(chs.length!==1?'s':''));
|
||||||
if(!chs.length){grid.innerHTML='<div class="empty">Nothing here.</div>';return}
|
if(!chs.length){grid.innerHTML='<div class="empty">Nothing here.</div>';return}
|
||||||
grid.innerHTML='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;padding:4px">'+chs.map((ch,i)=>{
|
grid.innerHTML='<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;padding:4px">'+chs.map((ch,i)=>{
|
||||||
const idx=library.indexOf(ch);
|
const idx=library.indexOf(ch);
|
||||||
const thumb=ch.thumb_url?`<img src="${ch.thumb_url}" loading="lazy" alt="">`:'<div class="nothumb">📺</div>';
|
const thumb=ch.thumb_url?`<img src="${ch.thumb_url}" loading="lazy" alt="">`:'<div class="nothumb">📺</div>';
|
||||||
const size=ch.size_bytes>0?` · ${fmtSize(ch.size_bytes)}`:'';
|
const size=ch.size_bytes>0?` · ${fmtSize(ch.size_bytes)}`:'';
|
||||||
|
|
@ -799,7 +799,7 @@ function renderMusicGrid(){
|
||||||
const tracks=q?musicTracks.filter(t=>t.title.toLowerCase().includes(q)||t.artist.toLowerCase().includes(q)):musicTracks;
|
const tracks=q?musicTracks.filter(t=>t.title.toLowerCase().includes(q)||t.artist.toLowerCase().includes(q)):musicTracks;
|
||||||
setStatus(tracks.length+' track'+(tracks.length!==1?'s':''));
|
setStatus(tracks.length+' track'+(tracks.length!==1?'s':''));
|
||||||
if(!tracks.length){grid.innerHTML='<div class="empty">No music yet. Use 🎵 Music mode in the download bar.</div>';return}
|
if(!tracks.length){grid.innerHTML='<div class="empty">No music yet. Use 🎵 Music mode in the download bar.</div>';return}
|
||||||
let currentArtist='',h='<div style="width:100%;padding:4px">';
|
let currentArtist='',h='<div style="grid-column:1/-1;width:100%;padding:4px">';
|
||||||
for(const t of tracks){
|
for(const t of tracks){
|
||||||
if(t.artist!==currentArtist){
|
if(t.artist!==currentArtist){
|
||||||
currentArtist=t.artist;
|
currentArtist=t.artist;
|
||||||
|
|
@ -994,9 +994,9 @@ async function openChannelOptions(idx){
|
||||||
<div class="settings-row"><label>Audio-only</label>
|
<div class="settings-row"><label>Audio-only</label>
|
||||||
<input type="checkbox" id="opt-audio" ${opts.audio_only?'checked':''}></div>
|
<input type="checkbox" id="opt-audio" ${opts.audio_only?'checked':''}></div>
|
||||||
<div class="settings-hint" style="margin-bottom:4px">Useful for music channels — saves disk + skips video re-encode.</div>
|
<div class="settings-hint" style="margin-bottom:4px">Useful for music channels — saves disk + skips video re-encode.</div>
|
||||||
<div class="settings-row"><label>Fetch comments</label>
|
<div class="settings-row"><label>Fetch comments (blank = global)</label>
|
||||||
<input type="checkbox" id="opt-comments" ${opts.fetch_comments?'checked':''}></div>
|
${triSelect('opt-comments',opts.fetch_comments)}</div>
|
||||||
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--write-comments</code>. Embeds the comment tree into info.json — slow for popular videos but enables the Comments tab in the player.</div>
|
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--write-comments</code>. Embeds the comment tree into info.json — slow for popular videos but enables the Comments tab in the player. "Default" uses the global setting.</div>
|
||||||
<div class="settings-row"><label>Skip auth check</label>
|
<div class="settings-row"><label>Skip auth check</label>
|
||||||
<input type="checkbox" id="opt-skipauth" ${opts.skip_auth_check?'checked':''}></div>
|
<input type="checkbox" id="opt-skipauth" ${opts.skip_auth_check?'checked':''}></div>
|
||||||
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--extractor-args youtubetab:skip=authcheck</code>. Safe for <strong>public</strong> channels — silences the "playlists that require authentication may not extract correctly" warning without changing which videos are found. Leave off for members-only/private channels, where that warning means your cookies may not be working.</div>
|
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--extractor-args youtubetab:skip=authcheck</code>. Safe for <strong>public</strong> channels — silences the "playlists that require authentication may not extract correctly" warning without changing which videos are found. Leave off for members-only/private channels, where that warning means your cookies may not be working.</div>
|
||||||
|
|
@ -1058,7 +1058,7 @@ function readChannelOptionsForm(){
|
||||||
return {
|
return {
|
||||||
quality:q?q:null,
|
quality:q?q:null,
|
||||||
audio_only:document.getElementById('opt-audio')?.checked||false,
|
audio_only:document.getElementById('opt-audio')?.checked||false,
|
||||||
fetch_comments:document.getElementById('opt-comments')?.checked||false,
|
fetch_comments:triValue('opt-comments'),
|
||||||
skip_auth_check:document.getElementById('opt-skipauth')?.checked||false,
|
skip_auth_check:document.getElementById('opt-skipauth')?.checked||false,
|
||||||
limit_rate_kb:intOrNull('opt-rate'),
|
limit_rate_kb:intOrNull('opt-rate'),
|
||||||
min_filesize_mb:intOrNull('opt-minsz'),
|
min_filesize_mb:intOrNull('opt-minsz'),
|
||||||
|
|
@ -1785,6 +1785,11 @@ async function openSettings(){
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
<div class="settings-row" style="margin-top:8px">
|
||||||
|
<label for="cf-comments">Fetch comments</label>
|
||||||
|
<input type="checkbox" id="cf-comments" ${cur.fetch_comments?'checked':''}>
|
||||||
|
</div>
|
||||||
|
<div class="settings-hint" style="margin-bottom:6px">Adds <code>--write-comments</code> to every download — fetches each video's comment tree into its info.json so the player's Comments tab works. Slow on popular videos. 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>
|
||||||
|
|
@ -2027,12 +2032,13 @@ async function saveSettings(btn){
|
||||||
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 sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark';
|
||||||
|
const fetchComments=document.getElementById('cf-comments')?.checked||false;
|
||||||
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,sponsorblock_mode:sponsorblock,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,fetch_comments:fetchComments,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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue