diff --git a/docs/tartube-spec.md b/docs/tartube-spec.md index b7259cc..2fe8dfc 100644 --- a/docs/tartube-spec.md +++ b/docs/tartube-spec.md @@ -571,8 +571,13 @@ For the record (to keep momentum honest): Working definition: a yt-offline release is "at Tartube parity" when the following are all true. -- [ ] **Per-target download options** with cascade resolution and a - per-channel editor. +- [x] **Per-target download options** with cascade resolution and a + per-channel editor. *v1 shipped 2026-05-25: 9 fields per channel + (quality cap, audio-only, rate limit, min/max filesize, date-after, + match-filter, subtitle langs, raw extra args), persisted in the + `channel_options` SQLite table, attached at scan time, applied at + every dispatch site (scheduled re-checks, right-click checks). + Editor surfaces in both UIs.* - [ ] **Folder hierarchy** with N-level nesting, drag-to-reparent, and per-folder default options. - [ ] **Smart folders** for Bookmarks / Favourites / Waiting / New / diff --git a/src/app.rs b/src/app.rs index b4dfb7e..1580f60 100644 --- a/src/app.rs +++ b/src/app.rs @@ -139,6 +139,30 @@ pub struct App { // Statistics window show_stats: bool, stats_report: Option, + // Per-channel download-options dialog state + show_channel_options: bool, + /// `(platform, handle)` identifying the channel being edited. + channel_options_target: Option<(Platform, String)>, + /// Editable form fields, mirroring [`DownloadOptions`] with string-typed + /// "text" buffers so the user can type partial values (e.g. an empty + /// limit-rate field) without us forcing zeroes back. + channel_options_form: ChannelOptionsForm, +} + +/// Scratch struct for the per-channel options dialog. Distinct from +/// [`crate::download_options::DownloadOptions`] because the user is allowed +/// to leave numeric fields blank (which we render as `None`). +#[derive(Default, Clone)] +struct ChannelOptionsForm { + quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p + audio_only: bool, + limit_rate_kb: String, + min_filesize_mb: String, + max_filesize_mb: String, + date_after: String, + match_filter: String, + subtitle_langs: String, // comma-separated + extra_args: String, // one per line } impl App { @@ -169,7 +193,7 @@ impl App { let dir = platform::platform_root(&channels_root, p); let _ = std::fs::create_dir_all(&dir); } - let library = library::scan_channels(&channels_root); + let mut library = library::scan_channels(&channels_root); let status = format!( "{} channels, {} videos", library.len(), @@ -179,6 +203,11 @@ impl App { let db_path = channels_root.join("yt-offline.db"); let db = Database::open(&db_path) .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); + // Hydrate per-channel download options from SQLite onto the scanned + // library before publishing it to the UI. + if let Ok(map) = db.get_all_channel_options() { + library::apply_channel_options(&mut library, &map); + } let watched = db.get_watched().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default(); @@ -269,11 +298,74 @@ impl App { health_report: None, show_stats: false, stats_report: None, + show_channel_options: false, + channel_options_target: None, + channel_options_form: ChannelOptionsForm::default(), + } + } + + /// Convert a [`DownloadOptions`] into the editable form state. Numeric + /// `None`s become empty strings. + fn options_to_form(opts: &crate::download_options::DownloadOptions) -> ChannelOptionsForm { + let quality_idx = match opts.quality { + None => 0, + Some(DownloadQuality::Best) => 1, + Some(DownloadQuality::Res1080) => 2, + Some(DownloadQuality::Res720) => 3, + Some(DownloadQuality::Res480) => 4, + Some(DownloadQuality::Res360) => 5, + }; + let num = |v: Option| v.map(|n| n.to_string()).unwrap_or_default(); + ChannelOptionsForm { + quality_idx, + audio_only: opts.audio_only, + limit_rate_kb: num(opts.limit_rate_kb), + min_filesize_mb: num(opts.min_filesize_mb), + max_filesize_mb: num(opts.max_filesize_mb), + date_after: opts.date_after.clone().unwrap_or_default(), + match_filter: opts.match_filter.clone().unwrap_or_default(), + subtitle_langs: opts.subtitle_langs.join(", "), + extra_args: opts.extra_args.join("\n"), + } + } + + /// Inverse of [`options_to_form`]. Empty/whitespace string fields map + /// back to `None`. + fn form_to_options(f: &ChannelOptionsForm) -> crate::download_options::DownloadOptions { + let quality = match f.quality_idx { + 1 => Some(DownloadQuality::Best), + 2 => Some(DownloadQuality::Res1080), + 3 => Some(DownloadQuality::Res720), + 4 => Some(DownloadQuality::Res480), + 5 => Some(DownloadQuality::Res360), + _ => None, + }; + let parse_num = |s: &str| s.trim().parse::().ok().filter(|&n| n > 0); + let trim_opt = |s: &str| { + let t = s.trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }; + crate::download_options::DownloadOptions { + quality, + audio_only: f.audio_only, + limit_rate_kb: parse_num(&f.limit_rate_kb), + min_filesize_mb: parse_num(&f.min_filesize_mb), + max_filesize_mb: parse_num(&f.max_filesize_mb), + date_after: trim_opt(&f.date_after), + match_filter: trim_opt(&f.match_filter), + subtitle_langs: f.subtitle_langs.split(',') + .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), + extra_args: f.extra_args.lines() + .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), } } fn rescan(&mut self) { - self.library = library::scan_channels(&self.channels_root); + let mut new_lib = library::scan_channels(&self.channels_root); + if let Ok(map) = self.db.get_all_channel_options() { + library::apply_channel_options(&mut new_lib, &map); + } + self.library = new_lib; self.music_library = library::scan_music(&self.music_root); self.sidebar_view = SidebarView::All; self.selected_video = None; @@ -588,15 +680,19 @@ impl App { } fn run_scheduled_check(&mut self) { - let mut count = 0; - let urls: Vec = self.library.iter() - .map(|ch| crate::downloader::recheck_url(ch)) + // Snapshot each channel's URL + options first so we don't hold an + // immutable borrow of `self.library` while calling + // `self.downloader.start` (which takes &mut self). + let scheduled: Vec<(String, crate::download_options::DownloadOptions)> = self.library.iter() + .map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone())) .collect(); - for url in urls { + let count = scheduled.len(); + for (url, opts) in scheduled { let info = classify_url(&url); - // Scheduled re-check: never treat as live. - self.downloader.start(url, &info, true, DownloadQuality::Best, false); - count += 1; + // Channel options' quality override (if any) wins over the + // hard-coded Best for scheduled re-checks. + let quality = opts.quality.unwrap_or(DownloadQuality::Best); + self.downloader.start(url, &info, true, quality, false, Some(&opts)); } self.status = format!("Scheduled check: started {} channel downloads", count); } @@ -793,6 +889,10 @@ impl App { // Collect any right-click download action outside the loop let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name) + // Same trick for the "Channel options…" menu item β€” capture + // the channel index, open the dialog after the loop so we + // don't recursively borrow `self` from inside the closure. + let mut pending_open_options: Option = None; for i in 0..self.library.len() { let (name, total, has_playlists, size_bytes, channel_url, platform) = { @@ -844,6 +944,7 @@ impl App { // Right-click context menu let url_for_menu = channel_url.clone(); let name_for_menu = name.clone(); + let channel_idx = i; resp.context_menu(|ui| { { let url = &url_for_menu; @@ -852,6 +953,10 @@ impl App { ui.close_menu(); } } + if ui.button("βš™ Channel options…").clicked() { + pending_open_options = Some(channel_idx); + ui.close_menu(); + } if ui.button("πŸ“ Open folder").clicked() { let path = self.library[i].path.clone(); if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() { @@ -878,10 +983,25 @@ impl App { } } - // Process deferred right-click download action + if let Some(ci) = pending_open_options { + if let Some(ch) = self.library.get(ci) { + self.channel_options_target = Some((ch.platform, ch.name.clone())); + self.channel_options_form = Self::options_to_form(&ch.download_options); + self.show_channel_options = true; + } + } + + // Process deferred right-click download action. The + // channel's own options + quality override apply for + // user-triggered re-checks just like for scheduled ones. if let Some((url, ch_name)) = pending_ch_download { let info = classify_url(&url); - self.downloader.start(url, &info, !self.dl_full_scan, DownloadQuality::Best, false); + let opts = self.library.iter() + .find(|c| c.name == ch_name) + .map(|c| c.download_options.clone()) + .unwrap_or_default(); + let quality = opts.quality.unwrap_or(DownloadQuality::Best); + self.downloader.start(url, &info, !self.dl_full_scan, quality, false, Some(&opts)); self.status = format!("Checking {} for new videos…", ch_name); } }); @@ -983,7 +1103,12 @@ impl App { self.downloader.start_music(url); self.status = "Downloading music…".to_string(); } else { - self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality, self.dl_live); + // Explicit submit: user already chose quality/live in + // the dialog. We don't know which channel the URL + // belongs to yet (yt-dlp resolves it), so no + // channel-options lookup β€” caller-side overrides + // win. + self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality, self.dl_live, None); self.status = format!("Downloading: {dest}"); } } @@ -1270,6 +1395,168 @@ impl App { } } + /// Per-channel download-options editor. Lives as a separate `egui::Window` + /// rather than a modal because egui doesn't really do modals. + fn channel_options_window(&mut self, ctx: &egui::Context) { + if !self.show_channel_options { return; } + let Some((platform, handle)) = self.channel_options_target.clone() else { + self.show_channel_options = false; + return; + }; + let mut open = self.show_channel_options; + let mut save = false; + let mut clear = false; + let mut cancel = false; + egui::Window::new(format!("βš™ {} Β· {}", platform.display_name(), handle)) + .open(&mut open) + .collapsible(false) + .resizable(true) + .default_width(440.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new( + "Overrides apply to scheduled re-checks and the right-click \ + \"Check for new videos\" action. Explicit downloads from the \ + URL bar still use the dialog's own picker.", + ) + .small() + .weak(), + ); + ui.separator(); + let form = &mut self.channel_options_form; + egui::Grid::new("ch_opts_grid").num_columns(2).spacing([12.0, 6.0]).striped(true).show(ui, |ui| { + ui.label("Quality cap"); + egui::ComboBox::from_id_salt("ch_opts_quality") + .selected_text(match form.quality_idx { + 1 => "Best", + 2 => "1080p", + 3 => "720p", + 4 => "480p", + 5 => "360p", + _ => "β€” Global default β€”", + }) + .show_ui(ui, |ui| { + for (idx, label) in [ + (0, "β€” Global default β€”"), + (1, "Best"), + (2, "1080p"), + (3, "720p"), + (4, "480p"), + (5, "360p"), + ] { + ui.selectable_value(&mut form.quality_idx, idx, label); + } + }); + ui.end_row(); + + ui.label("Audio-only"); + ui.checkbox(&mut form.audio_only, "Extract audio (best format)"); + ui.end_row(); + + ui.label("Bandwidth cap (KB/s)"); + ui.add(egui::TextEdit::singleline(&mut form.limit_rate_kb).desired_width(100.0).hint_text("off")); + ui.end_row(); + + ui.label("Min size (MB)"); + ui.add(egui::TextEdit::singleline(&mut form.min_filesize_mb).desired_width(100.0).hint_text("off")); + ui.end_row(); + + ui.label("Max size (MB)"); + ui.add(egui::TextEdit::singleline(&mut form.max_filesize_mb).desired_width(100.0).hint_text("off")); + ui.end_row(); + + ui.label("Date after (YYYYMMDD)"); + ui.add(egui::TextEdit::singleline(&mut form.date_after).desired_width(120.0).hint_text("e.g. 20240101")); + ui.end_row(); + }); + + ui.add_space(6.0); + ui.label("Match filter (yt-dlp --match-filter):"); + ui.add( + egui::TextEdit::singleline(&mut self.channel_options_form.match_filter) + .desired_width(f32::INFINITY) + .hint_text("e.g. duration > 60 & view_count > 100") + .font(egui::TextStyle::Monospace), + ); + + ui.add_space(6.0); + ui.label("Subtitle languages (comma separated, blank = all):"); + ui.add( + egui::TextEdit::singleline(&mut self.channel_options_form.subtitle_langs) + .desired_width(f32::INFINITY) + .hint_text("en, ja"), + ); + + ui.add_space(6.0); + ui.label("Extra yt-dlp args (one per line):"); + ui.add( + egui::TextEdit::multiline(&mut self.channel_options_form.extra_args) + .desired_rows(3) + .desired_width(f32::INFINITY) + .hint_text("--no-mtime\n--ignore-config") + .font(egui::TextStyle::Monospace), + ); + + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("πŸ—‘ Clear all").clicked() { clear = true; } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Save").clicked() { save = true; } + if ui.button("Cancel").clicked() { cancel = true; } + }); + }); + }); + + // Apply the decisions outside the closure so we can mutate self. + if save { + let opts = Self::form_to_options(&self.channel_options_form); + let platform_dir = platform.dir_name(); + let result = if opts.is_empty() { + self.db.delete_channel_options(platform_dir, &handle) + } else { + match serde_json::to_string(&opts) { + Ok(json) => self.db.set_channel_options(platform_dir, &handle, &json), + Err(e) => { + self.status = format!("Channel options: encode error: {e}"); + Ok(()) + } + } + }; + match result { + Ok(()) => { + // Reflect immediately on the in-memory library so the next + // re-check sees the change without waiting for a rescan. + for ch in self.library.iter_mut() { + if ch.platform == platform && ch.name == handle { + ch.download_options = opts.clone(); + break; + } + } + self.status = format!("Channel options saved for {handle}"); + self.show_channel_options = false; + } + Err(e) => self.status = format!("Channel options: {e}"), + } + } else if clear { + let platform_dir = platform.dir_name(); + if let Err(e) = self.db.delete_channel_options(platform_dir, &handle) { + self.status = format!("Channel options: {e}"); + } else { + for ch in self.library.iter_mut() { + if ch.platform == platform && ch.name == handle { + ch.download_options = Default::default(); + break; + } + } + self.status = format!("Channel options cleared for {handle}"); + self.show_channel_options = false; + } + } else if cancel || !open { + self.show_channel_options = false; + } + } + fn settings_window(&mut self, ctx: &egui::Context) { if !self.show_settings { return; @@ -2269,6 +2556,7 @@ impl eframe::App for App { self.settings_window(ctx); self.maintenance_window(ctx); self.stats_window(ctx); + self.channel_options_window(ctx); self.detail_panel(ctx); egui::CentralPanel::default().show(ctx, |ui| { self.video_list(ctx, ui); diff --git a/src/database.rs b/src/database.rs index b21acec..e60459f 100644 --- a/src/database.rs +++ b/src/database.rs @@ -103,11 +103,72 @@ impl Database { CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS channel_options ( + platform TEXT NOT NULL, + handle TEXT NOT NULL, + options_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (platform, handle) );", )?; Ok(()) } + /// Fetch the raw JSON-encoded download-options blob for a channel. + /// `platform` is the [`crate::platform::Platform::dir_name`] string; + /// `handle` is the on-disk folder name. Returns `None` when no options + /// row exists. + pub fn get_channel_options(&self, platform: &str, handle: &str) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT options_json FROM channel_options WHERE platform = ?1 AND handle = ?2", + )?; + let mut rows = stmt.query([platform, handle])?; + Ok(rows.next()?.map(|r| r.get(0)).transpose()?) + } + + /// Upsert the download-options JSON blob for a channel. + pub fn set_channel_options(&self, platform: &str, handle: &str, options_json: &str) -> Result<()> { + let conn = self.conn(); + conn.execute( + "INSERT OR REPLACE INTO channel_options (platform, handle, options_json, updated_at) + VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)", + [platform, handle, options_json], + )?; + Ok(()) + } + + /// Delete a channel's options row, falling its behavior back to global defaults. + pub fn delete_channel_options(&self, platform: &str, handle: &str) -> Result<()> { + let conn = self.conn(); + conn.execute( + "DELETE FROM channel_options WHERE platform = ?1 AND handle = ?2", + [platform, handle], + )?; + Ok(()) + } + + /// Bulk fetch of every channel's options, returned as + /// `((platform, handle) β†’ options_json)`. Used by the library scanner to + /// attach options to each scanned [`crate::library::Channel`] without + /// per-channel SQL round trips. + pub fn get_all_channel_options(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare("SELECT platform, handle, options_json FROM channel_options")?; + let map = stmt + .query_map([], |row| { + Ok(( + (row.get::<_, String>(0)?, row.get::<_, String>(1)?), + row.get::<_, String>(2)?, + )) + })? + .filter_map(std::result::Result::ok) + .map(|(k, v)| (k, v)) + .collect(); + Ok(map) + } + pub fn get_setting(&self, key: &str) -> Result> { let conn = self.conn(); let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?; diff --git a/src/download_options.rs b/src/download_options.rs new file mode 100644 index 0000000..bd5e7d5 --- /dev/null +++ b/src/download_options.rs @@ -0,0 +1,229 @@ +//! Per-channel download option overrides. +//! +//! Closes the largest gap with Tartube (its `OptionsManager` β€” 164 fields +//! attachable to any channel/playlist/video with cascading resolution). We +//! ship a focused v1 with ~10 of the most-used fields. The cascade currently +//! has two levels: +//! +//! - **Channel options** β€” set per `(platform, handle)` pair via the UI and +//! stored in the `channel_options` SQLite table. +//! - **Global defaults** β€” what [`crate::downloader::Downloader`] applies +//! when no channel options exist. +//! +//! Future expansion (Phase 1 follow-up): folder-level options + a named +//! "options manager" pool that channels can reference by id. + +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::downloader::DownloadQuality; + +/// Per-channel overrides applied on top of the standard yt-dlp flags +/// emitted by [`crate::downloader::Downloader::start`]. Every field is +/// optional / empty-by-default so an empty `DownloadOptions` is a no-op. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct DownloadOptions { + /// Quality cap for this channel's videos. When `Some`, overrides the + /// quality picker the user chose at submit time *for re-checks + /// originating from the channel*. Explicit submits from the download + /// dialog still use whatever the user selected there. + #[serde(default)] + pub quality: Option, + + /// Force audio-only extraction for this channel. Adds + /// `--extract-audio --audio-format best --audio-quality 0`. + #[serde(default)] + pub audio_only: bool, + + /// Bandwidth cap in kilobytes per second. Maps to `--limit-rate K`. + /// `None` for no limit. + #[serde(default)] + pub limit_rate_kb: Option, + + /// Skip videos smaller than this many megabytes (yt-dlp `--min-filesize`). + #[serde(default)] + pub min_filesize_mb: Option, + + /// Skip videos larger than this many megabytes (yt-dlp `--max-filesize`). + /// Useful for filtering "music" channels that occasionally drop full + /// concerts you don't want. + #[serde(default)] + pub max_filesize_mb: Option, + + /// Only download videos uploaded on or after this date (`YYYYMMDD`). + /// Maps to yt-dlp `--dateafter`. + #[serde(default)] + pub date_after: Option, + + /// Free-form yt-dlp `--match-filter` expression. Power-user feature; the + /// UI surfaces it as a single textbox with a pointer to yt-dlp docs. + #[serde(default)] + pub match_filter: Option, + + /// Subtitle languages to fetch. `["en"]` for English-only, + /// `["en", "ja"]` for English + Japanese, etc. Empty for the global + /// default (which is auto + manual via `--write-subs --write-auto-subs`). + #[serde(default)] + pub subtitle_langs: Vec, + + /// Raw passthrough β€” every entry is appended as a separate argument to + /// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent + /// to Tartube's `extra_cmd_string`. + #[serde(default)] + pub extra_args: Vec, +} + +impl DownloadOptions { + /// True when every field is at its default. Lets callers cheaply detect + /// an effectively-blank options row and avoid storing it. + pub fn is_empty(&self) -> bool { + self == &DownloadOptions::default() + } + + /// Append this channel's option overrides to a yt-dlp `Command`. + /// Called by [`crate::downloader::Downloader::start`] after the standard + /// flag set, so an explicit override here wins. + pub fn apply(&self, cmd: &mut Command) { + if self.audio_only { + // Mirror what `start_music` does: extract the best audio in its + // native format. Don't force a re-encode β€” that'd lose quality. + cmd.arg("--extract-audio") + .arg("--audio-format").arg("best") + .arg("--audio-quality").arg("0"); + } + if let Some(rate) = self.limit_rate_kb { + cmd.arg("--limit-rate").arg(format!("{rate}K")); + } + if let Some(min) = self.min_filesize_mb { + cmd.arg("--min-filesize").arg(format!("{min}M")); + } + if let Some(max) = self.max_filesize_mb { + cmd.arg("--max-filesize").arg(format!("{max}M")); + } + if let Some(date) = &self.date_after { + if !date.is_empty() { + cmd.arg("--dateafter").arg(date); + } + } + if let Some(filter) = &self.match_filter { + if !filter.is_empty() { + cmd.arg("--match-filter").arg(filter); + } + } + if !self.subtitle_langs.is_empty() { + cmd.arg("--sub-langs").arg(self.subtitle_langs.join(",")); + } + for arg in &self.extra_args { + if !arg.is_empty() { + cmd.arg(arg); + } + } + } + + /// Deserialize from the JSON blob stored in `channel_options.options_json`. + /// Bad rows are logged and treated as "no options" so a corrupt or + /// schema-drifted row doesn't take a channel offline. + pub fn from_json(s: &str) -> Self { + match serde_json::from_str::(s) { + Ok(o) => o, + Err(e) => { + eprintln!("channel_options: ignoring malformed row: {e}"); + DownloadOptions::default() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_options_emit_nothing() { + let mut cmd = Command::new("yt-dlp"); + DownloadOptions::default().apply(&mut cmd); + // The Command has just the program name, no extra args. + let args: Vec<_> = cmd.get_args().collect(); + assert!(args.is_empty()); + } + + #[test] + fn limit_rate_emits_correct_flag() { + let mut cmd = Command::new("yt-dlp"); + DownloadOptions { + limit_rate_kb: Some(500), + ..Default::default() + } + .apply(&mut cmd); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args, vec!["--limit-rate", "500K"]); + } + + #[test] + fn subtitle_langs_join_with_commas() { + let mut cmd = Command::new("yt-dlp"); + DownloadOptions { + subtitle_langs: vec!["en".into(), "ja".into(), "es".into()], + ..Default::default() + } + .apply(&mut cmd); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args, vec!["--sub-langs", "en,ja,es"]); + } + + #[test] + fn audio_only_adds_extract_audio_chain() { + let mut cmd = Command::new("yt-dlp"); + DownloadOptions { + audio_only: true, + ..Default::default() + } + .apply(&mut cmd); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert!(args.windows(2).any(|w| w == ["--extract-audio".to_string(), "--audio-format".to_string()])); + } + + #[test] + fn extra_args_pass_through_verbatim() { + let mut cmd = Command::new("yt-dlp"); + DownloadOptions { + extra_args: vec!["--no-cache-dir".into(), "--verbose".into()], + ..Default::default() + } + .apply(&mut cmd); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args, vec!["--no-cache-dir", "--verbose"]); + } + + #[test] + fn json_roundtrip() { + let original = DownloadOptions { + quality: Some(DownloadQuality::Res1080), + audio_only: false, + limit_rate_kb: Some(1024), + subtitle_langs: vec!["en".into()], + extra_args: vec!["--no-mtime".into()], + ..Default::default() + }; + let json = serde_json::to_string(&original).unwrap(); + let roundtrip = DownloadOptions::from_json(&json); + assert_eq!(original, roundtrip); + } + + #[test] + fn corrupt_json_falls_back_to_default() { + let parsed = DownloadOptions::from_json("not even json {"); + assert_eq!(parsed, DownloadOptions::default()); + } + + #[test] + fn is_empty_detects_default() { + assert!(DownloadOptions::default().is_empty()); + assert!(!DownloadOptions { + audio_only: true, + ..Default::default() + } + .is_empty()); + } +} diff --git a/src/downloader.rs b/src/downloader.rs index 57bf589..755d541 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -30,11 +30,12 @@ use std::process::{Command, Stdio}; use std::sync::mpsc::{channel, Receiver}; use std::thread; +use crate::download_options::DownloadOptions; use crate::platform::{self, Platform, UrlInfo, UrlKind}; use crate::ytdlp_bin; /// Video quality level passed as a `-f` format selector to yt-dlp. -#[derive(Clone, Copy, PartialEq, Eq, Default)] +#[derive(Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize, Debug)] pub enum DownloadQuality { /// No `-f` flag β€” yt-dlp picks the best available streams (default). #[default] @@ -294,7 +295,22 @@ impl Downloader { /// added, `--break-on-existing` is suppressed (each recording is unique), /// and the output filename gains a UTC timestamp suffix so re-recordings /// of the same channel don't collide. - pub fn start(&mut self, url: String, info: &UrlInfo, full_scan: bool, quality: DownloadQuality, live: bool) { + /// + /// `channel_options` carries per-channel overrides (rate limit, filters, + /// extra args, …) and is applied after the standard flag set so it can + /// win. Pass `None` when the caller doesn't know which channel the URL + /// belongs to (e.g. an arbitrary URL pasted into the download dialog). + /// The channel-options `quality` field overrides the `quality` parameter + /// only when the caller explicitly opts in by passing it through. + pub fn start( + &mut self, + url: String, + info: &UrlInfo, + full_scan: bool, + quality: DownloadQuality, + live: bool, + channel_options: Option<&DownloadOptions>, + ) { let platform_dir = platform::platform_root(&self.channels_root, info.platform); // Per-platform download archive keeps cross-platform IDs from colliding // (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.). @@ -391,6 +407,12 @@ impl Downloader { .arg(archive_path.display().to_string()); self.apply_impersonation(info.platform, &mut cmd); self.apply_platform_extras(info.platform, &mut cmd); + // Per-channel option overrides win when present β€” they're applied + // last so a `--limit-rate` / `--match-filter` / passthrough arg from + // the channel settings takes priority over the global defaults. + if let Some(opts) = channel_options { + opts.apply(&mut cmd); + } cmd.arg("-o").arg(&out_arg).arg(&url); Self::apply_retry_flags(&mut cmd); diff --git a/src/library.rs b/src/library.rs index 2ce3e47..749ea44 100644 --- a/src/library.rs +++ b/src/library.rs @@ -22,6 +22,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use crate::download_options::DownloadOptions; use crate::platform::{self, Platform}; const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"]; @@ -117,6 +118,12 @@ pub struct Channel { pub total_videos_cached: usize, /// Cached sum of all video file sizes. pub total_size_cached: u64, + /// Per-channel download-option overrides loaded from the SQLite + /// `channel_options` table. Empty by default, meaning "use globals". + /// The scanner leaves this as `default()`; callers populate it after the + /// scan by reading the DB once via + /// [`crate::database::Database::get_all_channel_options`]. + pub download_options: DownloadOptions, } impl Channel { @@ -133,6 +140,24 @@ impl Channel { } } +/// Mutate `channels` in place, filling each one's [`Channel::download_options`] +/// from the supplied `(platform_dir_name, handle) β†’ options_json` map. +/// +/// The caller normally builds the map with +/// [`crate::database::Database::get_all_channel_options`] right after a +/// scan / rescan and before publishing the library to the UI. +pub fn apply_channel_options( + channels: &mut [Channel], + options_map: &std::collections::HashMap<(String, String), String>, +) { + for ch in channels { + let key = (ch.platform.dir_name().to_string(), ch.name.clone()); + if let Some(json) = options_map.get(&key) { + ch.download_options = DownloadOptions::from_json(json); + } + } +} + /// Find a video by ID across a slice of channels. Returns the matching /// [`Video`] alongside the channel it belongs to. pub fn find_video<'a>(channels: &'a [Channel], id: &str) -> Option<(&'a Video, &'a Channel)> { @@ -196,6 +221,7 @@ pub fn scan_channels(youtube_root: &Path) -> Vec { meta, total_videos_cached, total_size_cached, + download_options: DownloadOptions::default(), }) }) .into_iter() diff --git a/src/main.rs b/src/main.rs index b80d0fb..31ea612 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod app; mod config; mod database; +mod download_options; mod downloader; mod library; mod maintenance; diff --git a/src/web.rs b/src/web.rs index 70a8f17..be5f76e 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1006,7 +1006,10 @@ async fn post_download( _ => DownloadQuality::Best, }; let info = classify_url(&url); - dl.start(url, &info, body.full_scan, quality, body.live); + // Submit from the web download dialog: the user just chose the + // quality/live/full_scan values. We don't yet know which channel + // the URL belongs to, so channel options aren't applied here. + dl.start(url, &info, body.full_scan, quality, body.live, None); } (StatusCode::ACCEPTED, "ok").into_response() } @@ -1452,7 +1455,10 @@ async fn get_description( } async fn post_rescan(State(state): State>) -> impl IntoResponse { - let new_lib = library::scan_channels(&state.channels_root); + let mut new_lib = library::scan_channels(&state.channels_root); + if let Ok(map) = state.db.get_all_channel_options() { + library::apply_channel_options(&mut new_lib, &map); + } // Refresh watched from DB after rescan if let Ok(w) = state.db.get_watched() { *state.watched.lock().unwrap() = w; @@ -1500,7 +1506,10 @@ async fn post_maintenance_remove( ) -> impl IntoResponse { let (removed, errors) = maintenance::remove_files(&state.library_root, &body.paths); // Refresh the library so the removed copies disappear from the UI. - let new_lib = library::scan_channels(&state.channels_root); + let mut new_lib = library::scan_channels(&state.channels_root); + if let Ok(map) = state.db.get_all_channel_options() { + library::apply_channel_options(&mut new_lib, &map); + } *state.library.lock().unwrap() = new_lib; bump_library_version(&state); Json(serde_json::json!({ "removed": removed, "errors": errors })) @@ -1523,22 +1532,95 @@ async fn post_maintenance_repair( } /// `POST /api/scheduler/run` β€” trigger an immediate scheduled channel check. +/// `GET /api/channels/:platform/:handle/options` β€” fetch the stored +/// download-option overrides for a single channel. Returns the default +/// (empty) options when nothing is stored. +async fn get_channel_options( + State(state): State>, + Path((platform, handle)): Path<(String, String)>, +) -> impl IntoResponse { + let opts = match state.db.get_channel_options(&platform, &handle) { + Ok(Some(json)) => crate::download_options::DownloadOptions::from_json(&json), + _ => crate::download_options::DownloadOptions::default(), + }; + Json(opts).into_response() +} + +/// `POST /api/channels/:platform/:handle/options` β€” upsert the overrides. +/// An empty-by-default body is stored as DELETE so we don't keep useless rows. +async fn post_channel_options( + State(state): State>, + Path((platform, handle)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + let result = if body.is_empty() { + state.db.delete_channel_options(&platform, &handle) + } else { + match serde_json::to_string(&body) { + Ok(json) => state.db.set_channel_options(&platform, &handle, &json), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("encode: {e}")).into_response(), + } + }; + if let Err(e) = result { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(); + } + // Push the new options onto the live library snapshot so a re-check + // triggered before the next rescan still sees them. + { + let mut lib = state.library.lock().unwrap(); + for ch in lib.iter_mut() { + if ch.platform.dir_name() == platform && ch.name == handle { + ch.download_options = body.clone(); + break; + } + } + } + bump_library_version(&state); + (StatusCode::OK, "ok").into_response() +} + +/// `DELETE /api/channels/:platform/:handle/options` β€” clear overrides, returning +/// the channel to global defaults. +async fn delete_channel_options( + State(state): State>, + Path((platform, handle)): Path<(String, String)>, +) -> impl IntoResponse { + if let Err(e) = state.db.delete_channel_options(&platform, &handle) { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(); + } + { + let mut lib = state.library.lock().unwrap(); + for ch in lib.iter_mut() { + if ch.platform.dir_name() == platform && ch.name == handle { + ch.download_options = Default::default(); + break; + } + } + } + bump_library_version(&state); + (StatusCode::OK, "cleared").into_response() +} + async fn post_scheduler_run(State(state): State>) -> impl IntoResponse { if state.downloader.lock().unwrap().any_running() { return (StatusCode::CONFLICT, "downloads already running").into_response(); } - let urls: Vec = state.library.lock().unwrap() - .iter() - .map(|ch| crate::downloader::recheck_url(ch)) - .collect(); - if urls.is_empty() { + // Snapshot (url, options) pairs so we can iterate without holding the + // library lock through start(). + let scheduled: Vec<(String, crate::download_options::DownloadOptions)> = + state.library.lock().unwrap() + .iter() + .map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone())) + .collect(); + if scheduled.is_empty() { return (StatusCode::OK, "no channels to check").into_response(); } - let count = urls.len(); + let count = scheduled.len(); let mut dl = state.downloader.lock().unwrap(); - for url in urls { + for (url, opts) in scheduled { let info = classify_url(&url); - dl.start(url, &info, true, DownloadQuality::Best, false); + let quality = opts.quality.unwrap_or(DownloadQuality::Best); + dl.start(url, &info, true, quality, false, Some(&opts)); } *state.last_scheduled_check.lock().unwrap() = Some(std::time::Instant::now()); (StatusCode::ACCEPTED, format!("started {count} channel checks")).into_response() @@ -1647,9 +1729,12 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .unwrap_or_else(|_| PathBuf::from(".")) .join("config.toml"); - let library = library::scan_channels(&channels_root); + let mut library = library::scan_channels(&channels_root); let db = Database::open(&db_path) .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); + if let Ok(map) = db.get_all_channel_options() { + library::apply_channel_options(&mut library, &map); + } let watched = db.get_watched().unwrap_or_default(); let positions = db.get_positions().unwrap_or_default(); @@ -1705,15 +1790,17 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { last.map_or(true, |t| t.elapsed() >= interval_dur) }; if !due { continue; } - let urls: Vec = sched_state.library.lock().unwrap() - .iter() - .map(|ch| crate::downloader::recheck_url(ch)) - .collect(); - if urls.is_empty() { continue; } + let scheduled: Vec<(String, crate::download_options::DownloadOptions)> = + sched_state.library.lock().unwrap() + .iter() + .map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone())) + .collect(); + if scheduled.is_empty() { continue; } let mut dl = sched_state.downloader.lock().unwrap(); - for url in urls { + for (url, opts) in scheduled { let info = classify_url(&url); - dl.start(url, &info, true, DownloadQuality::Best, false); + let quality = opts.quality.unwrap_or(DownloadQuality::Best); + dl.start(url, &info, true, quality, false, Some(&opts)); } *sched_state.last_scheduled_check.lock().unwrap() = Some(std::time::Instant::now()); } @@ -1742,6 +1829,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/maintenance/repair/:id", post(post_maintenance_repair)) .route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies)) .route("/api/scheduler/run", post(post_scheduler_run)) + .route( + "/api/channels/:platform/:handle/options", + get(get_channel_options).post(post_channel_options).delete(delete_channel_options), + ) .route("/api/stats", get(get_stats)) .route("/api/ytdlp/update", post(post_ytdlp_update)) .route("/api/music", get(get_music)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 34cdd38..aa3f6fc 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -243,6 +243,7 @@ function renderSidebar(){ h+=`
β”” ${esc(pl.name)} (${pl.videos.length})
`; } h+=`
⬇ Check for new videos
`; + h+=`
βš™ Channel options…
`; } } el.innerHTML=h; @@ -485,6 +486,102 @@ function rebuildChannelUrl(ch){ } async function downloadChannelByIdx(i){await downloadChannel(rebuildChannelUrl(library[i]))} +/* ── Per-channel download options ──────────────────────────────── */ +async function openChannelOptions(idx){ + const ch=library[idx]; if(!ch)return; + const platform=ch.platform, handle=ch.name; + let opts={}; + try{ + opts=await(await api(`/api/channels/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/options`)).json(); + }catch(e){setStatus('Could not load options: '+e.message);return} + const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()}; + const q=opts.quality||''; + const qOpt=(val,label)=>``; + const langs=(opts.subtitle_langs||[]).join(', '); + const extras=(opts.extra_args||[]).join('\n'); + bg.innerHTML=``; + document.body.appendChild(bg); +} +function readChannelOptionsForm(){ + const intOrNull=id=>{const v=parseInt(document.getElementById(id)?.value,10);return Number.isFinite(v)&&v>0?v:null}; + const strOrNull=id=>{const v=(document.getElementById(id)?.value||'').trim();return v?v:null}; + const subs=(document.getElementById('opt-subs')?.value||'').split(',').map(s=>s.trim()).filter(Boolean); + const extra=(document.getElementById('opt-extra')?.value||'').split('\n').map(s=>s.trim()).filter(Boolean); + const q=document.getElementById('opt-quality')?.value||''; + return { + quality:q?q:null, + audio_only:document.getElementById('opt-audio')?.checked||false, + limit_rate_kb:intOrNull('opt-rate'), + min_filesize_mb:intOrNull('opt-minsz'), + max_filesize_mb:intOrNull('opt-maxsz'), + date_after:strOrNull('opt-date'), + match_filter:strOrNull('opt-filter'), + subtitle_langs:subs, + extra_args:extra, + }; +} +async function saveChannelOptions(platform,handle,btn){ + btn.disabled=true; + try{ + const body=readChannelOptionsForm(); + const r=await api(`/api/channels/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/options`,{ + method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + if(!r.ok)throw new Error(await r.text()); + btn.closest('.modal-bg').remove(); + setStatus('Channel options saved'); + await loadLibrary(); + }catch(e){setStatus('Error: '+e.message);btn.disabled=false} +} +async function clearChannelOptions(platform,handle,btn){ + if(!confirm('Clear all channel option overrides? The channel will fall back to global defaults.'))return; + btn.disabled=true; + try{ + await api(`/api/channels/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/options`,{method:'DELETE'}); + btn.closest('.modal-bg').remove(); + setStatus('Channel options cleared'); + await loadLibrary(); + }catch(e){setStatus('Error: '+e.message);btn.disabled=false} +} + /* ── Rescan ─────────────────────────────────────────────────────── */ async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}