Per-channel download options (Tartube parity Phase 1.1)

Closes the single largest gap with Tartube documented in docs/tartube-spec.md.
Users can now attach a small set of yt-dlp overrides to any individual
channel; the overrides apply automatically during scheduled re-checks
and the right-click "Check for new videos" action.

Data layer
- New `channel_options` SQLite table keyed on (platform, handle), with
  `options_json TEXT` blob + updated_at. PK ensures one row per channel.
- Database gains get_channel_options / set_channel_options /
  delete_channel_options / get_all_channel_options. The bulk getter is
  used by the library scanner so each rescan hydrates options without
  per-channel SQL round-trips.
- `DownloadQuality` derives Serialize/Deserialize so it can roundtrip
  through the options blob.

New module: src/download_options.rs
- `DownloadOptions` struct with 9 fields:
    quality (Option<DownloadQuality>) — per-channel quality cap
    audio_only (bool) — force --extract-audio chain
    limit_rate_kb (Option<u32>) — --limit-rate
    min_filesize_mb / max_filesize_mb — yt-dlp filesize filters
    date_after (Option<String>) — --dateafter YYYYMMDD
    match_filter (Option<String>) — yt-dlp --match-filter passthrough
    subtitle_langs (Vec<String>) — --sub-langs CSV
    extra_args (Vec<String>) — raw yt-dlp passthrough (Tartube's
        `extra_cmd_string`)
- `apply(&mut Command)` appends the right flags conditionally.
- `is_empty()` lets the API layer collapse no-op writes into DELETEs so
  we don't keep useless rows around.
- `from_json()` falls back to defaults on malformed rows so a schema
  drift doesn't take a channel offline.
- 8 new unit tests cover apply-emits-correct-flags, JSON roundtrip,
  corrupt-fallback, and is_empty.

Library
- `Channel` gains `download_options: DownloadOptions` (default empty).
- New `library::apply_channel_options(channels, map)` helper that the
  caller invokes after `scan_channels` to hydrate from the bulk DB load.
- All five `scan_channels` call sites updated.

Downloader
- `Downloader::start` gains a new `channel_options: Option<&DownloadOptions>`
  parameter. Applied last so per-channel overrides win over global
  defaults. The explicit "submit from URL bar" flow passes None
  (channel unknown until yt-dlp resolves it). Scheduled re-checks +
  right-click checks pass the channel's stored options. Channel-options'
  `quality` field overrides the hard-coded DownloadQuality::Best for
  re-checks.

Web API
- New routes on /api/channels/:platform/:handle/options:
    GET    — fetch (returns defaults when no row exists)
    POST   — upsert (empty body collapses to DELETE)
    DELETE — clear overrides
- All three update the in-memory library snapshot immediately so the
  next re-check sees the change without waiting for a rescan, and bump
  the library_version ETag so polling clients pick up the new state.

Web UI
- Sidebar each channel's expanded section gains a "⚙ Channel options…"
  entry next to "Check for new videos".
- openChannelOptions() fetches the current options + renders a modal
  with one input per field. Save / Clear / Cancel buttons. The Clear
  button confirms via window.confirm() because it's destructive.
- All form fields validate locally before POSTing; empty numeric
  fields map to null.

Desktop UI
- New `channel_options_window` egui::Window opened from the existing
  channel right-click context menu (new "⚙ Channel options…" item).
- Form mirrors the web side: ComboBox for quality, checkbox for
  audio-only, TextEdit (singleline + multiline) for the other fields.
- Save / Clear / Cancel buttons; saves go straight to SQLite and the
  live `App::library` so the next scheduled-check or right-click runs
  with the new overrides.

Documentation
- `docs/tartube-spec.md` checklist: per-target download options ticked
  with a note describing the v1 scope.

55 unit tests pass (47 + 8 new).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-25 10:47:57 -07:00
parent c713d05db8
commit 9335e0faa8
9 changed files with 855 additions and 35 deletions

View file

@ -571,8 +571,13 @@ For the record (to keep momentum honest):
Working definition: a yt-offline release is "at Tartube parity" when Working definition: a yt-offline release is "at Tartube parity" when
the following are all true. the following are all true.
- [ ] **Per-target download options** with cascade resolution and a - [x] **Per-target download options** with cascade resolution and a
per-channel editor. 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 - [ ] **Folder hierarchy** with N-level nesting, drag-to-reparent, and
per-folder default options. per-folder default options.
- [ ] **Smart folders** for Bookmarks / Favourites / Waiting / New / - [ ] **Smart folders** for Bookmarks / Favourites / Waiting / New /

View file

@ -139,6 +139,30 @@ pub struct App {
// Statistics window // Statistics window
show_stats: bool, show_stats: bool,
stats_report: Option<crate::stats::StatsReport>, stats_report: Option<crate::stats::StatsReport>,
// 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 { impl App {
@ -169,7 +193,7 @@ impl App {
let dir = platform::platform_root(&channels_root, p); let dir = platform::platform_root(&channels_root, p);
let _ = std::fs::create_dir_all(&dir); 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!( let status = format!(
"{} channels, {} videos", "{} channels, {} videos",
library.len(), library.len(),
@ -179,6 +203,11 @@ 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"));
// 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 watched = db.get_watched().unwrap_or_default();
let resume_positions = db.get_positions().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default();
@ -269,11 +298,74 @@ impl App {
health_report: None, health_report: None,
show_stats: false, show_stats: false,
stats_report: None, 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<u32>| 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::<u32>().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) { 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.music_library = library::scan_music(&self.music_root);
self.sidebar_view = SidebarView::All; self.sidebar_view = SidebarView::All;
self.selected_video = None; self.selected_video = None;
@ -588,15 +680,19 @@ impl App {
} }
fn run_scheduled_check(&mut self) { fn run_scheduled_check(&mut self) {
let mut count = 0; // Snapshot each channel's URL + options first so we don't hold an
let urls: Vec<String> = self.library.iter() // immutable borrow of `self.library` while calling
.map(|ch| crate::downloader::recheck_url(ch)) // `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(); .collect();
for url in urls { let count = scheduled.len();
for (url, opts) in scheduled {
let info = classify_url(&url); let info = classify_url(&url);
// Scheduled re-check: never treat as live. // Channel options' quality override (if any) wins over the
self.downloader.start(url, &info, true, DownloadQuality::Best, false); // hard-coded Best for scheduled re-checks.
count += 1; 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); self.status = format!("Scheduled check: started {} channel downloads", count);
} }
@ -793,6 +889,10 @@ impl App {
// Collect any right-click download action outside the loop // Collect any right-click download action outside the loop
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name) 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<usize> = None;
for i in 0..self.library.len() { for i in 0..self.library.len() {
let (name, total, has_playlists, size_bytes, channel_url, platform) = { let (name, total, has_playlists, size_bytes, channel_url, platform) = {
@ -844,6 +944,7 @@ impl App {
// Right-click context menu // Right-click context menu
let url_for_menu = channel_url.clone(); let url_for_menu = channel_url.clone();
let name_for_menu = name.clone(); let name_for_menu = name.clone();
let channel_idx = i;
resp.context_menu(|ui| { resp.context_menu(|ui| {
{ {
let url = &url_for_menu; let url = &url_for_menu;
@ -852,6 +953,10 @@ impl App {
ui.close_menu(); ui.close_menu();
} }
} }
if ui.button("⚙ Channel options…").clicked() {
pending_open_options = Some(channel_idx);
ui.close_menu();
}
if ui.button("📁 Open folder").clicked() { if ui.button("📁 Open folder").clicked() {
let path = self.library[i].path.clone(); let path = self.library[i].path.clone();
if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() { 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 { if let Some((url, ch_name)) = pending_ch_download {
let info = classify_url(&url); 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); self.status = format!("Checking {} for new videos…", ch_name);
} }
}); });
@ -983,7 +1103,12 @@ impl App {
self.downloader.start_music(url); self.downloader.start_music(url);
self.status = "Downloading music…".to_string(); self.status = "Downloading music…".to_string();
} else { } 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}"); 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) { fn settings_window(&mut self, ctx: &egui::Context) {
if !self.show_settings { if !self.show_settings {
return; return;
@ -2269,6 +2556,7 @@ impl eframe::App for App {
self.settings_window(ctx); self.settings_window(ctx);
self.maintenance_window(ctx); self.maintenance_window(ctx);
self.stats_window(ctx); self.stats_window(ctx);
self.channel_options_window(ctx);
self.detail_panel(ctx); self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
self.video_list(ctx, ui); self.video_list(ctx, ui);

View file

@ -103,11 +103,72 @@ impl Database {
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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(()) 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<Option<String>> {
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<HashMap<(String, String), String>> {
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<Option<String>> { pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn(); let conn = self.conn();
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?; let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;

229
src/download_options.rs Normal file
View file

@ -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<DownloadQuality>,
/// 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 <N>K`.
/// `None` for no limit.
#[serde(default)]
pub limit_rate_kb: Option<u32>,
/// Skip videos smaller than this many megabytes (yt-dlp `--min-filesize`).
#[serde(default)]
pub min_filesize_mb: Option<u32>,
/// 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<u32>,
/// Only download videos uploaded on or after this date (`YYYYMMDD`).
/// Maps to yt-dlp `--dateafter`.
#[serde(default)]
pub date_after: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
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::<DownloadOptions>(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<String> = 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<String> = 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<String> = 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<String> = 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());
}
}

View file

@ -30,11 +30,12 @@ use std::process::{Command, Stdio};
use std::sync::mpsc::{channel, Receiver}; use std::sync::mpsc::{channel, Receiver};
use std::thread; use std::thread;
use crate::download_options::DownloadOptions;
use crate::platform::{self, Platform, UrlInfo, UrlKind}; use crate::platform::{self, Platform, UrlInfo, UrlKind};
use crate::ytdlp_bin; use crate::ytdlp_bin;
/// Video quality level passed as a `-f` format selector to yt-dlp. /// 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 { pub enum DownloadQuality {
/// No `-f` flag — yt-dlp picks the best available streams (default). /// No `-f` flag — yt-dlp picks the best available streams (default).
#[default] #[default]
@ -294,7 +295,22 @@ impl Downloader {
/// added, `--break-on-existing` is suppressed (each recording is unique), /// added, `--break-on-existing` is suppressed (each recording is unique),
/// and the output filename gains a UTC timestamp suffix so re-recordings /// and the output filename gains a UTC timestamp suffix so re-recordings
/// of the same channel don't collide. /// 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); let platform_dir = platform::platform_root(&self.channels_root, info.platform);
// Per-platform download archive keeps cross-platform IDs from colliding // Per-platform download archive keeps cross-platform IDs from colliding
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.). // (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
@ -391,6 +407,12 @@ impl Downloader {
.arg(archive_path.display().to_string()); .arg(archive_path.display().to_string());
self.apply_impersonation(info.platform, &mut cmd); self.apply_impersonation(info.platform, &mut cmd);
self.apply_platform_extras(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); cmd.arg("-o").arg(&out_arg).arg(&url);
Self::apply_retry_flags(&mut cmd); Self::apply_retry_flags(&mut cmd);

View file

@ -22,6 +22,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::download_options::DownloadOptions;
use crate::platform::{self, Platform}; use crate::platform::{self, Platform};
const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"]; const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
@ -117,6 +118,12 @@ pub struct Channel {
pub total_videos_cached: usize, pub total_videos_cached: usize,
/// Cached sum of all video file sizes. /// Cached sum of all video file sizes.
pub total_size_cached: u64, 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 { 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 /// Find a video by ID across a slice of channels. Returns the matching
/// [`Video`] alongside the channel it belongs to. /// [`Video`] alongside the channel it belongs to.
pub fn find_video<'a>(channels: &'a [Channel], id: &str) -> Option<(&'a Video, &'a Channel)> { 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<Channel> {
meta, meta,
total_videos_cached, total_videos_cached,
total_size_cached, total_size_cached,
download_options: DownloadOptions::default(),
}) })
}) })
.into_iter() .into_iter()

View file

@ -16,6 +16,7 @@
mod app; mod app;
mod config; mod config;
mod database; mod database;
mod download_options;
mod downloader; mod downloader;
mod library; mod library;
mod maintenance; mod maintenance;

View file

@ -1006,7 +1006,10 @@ async fn post_download(
_ => DownloadQuality::Best, _ => DownloadQuality::Best,
}; };
let info = classify_url(&url); 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() (StatusCode::ACCEPTED, "ok").into_response()
} }
@ -1452,7 +1455,10 @@ async fn get_description(
} }
async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse { async fn post_rescan(State(state): State<Arc<WebState>>) -> 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 // Refresh watched from DB after rescan
if let Ok(w) = state.db.get_watched() { if let Ok(w) = state.db.get_watched() {
*state.watched.lock().unwrap() = w; *state.watched.lock().unwrap() = w;
@ -1500,7 +1506,10 @@ async fn post_maintenance_remove(
) -> impl IntoResponse { ) -> impl IntoResponse {
let (removed, errors) = maintenance::remove_files(&state.library_root, &body.paths); let (removed, errors) = maintenance::remove_files(&state.library_root, &body.paths);
// Refresh the library so the removed copies disappear from the UI. // 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; *state.library.lock().unwrap() = new_lib;
bump_library_version(&state); bump_library_version(&state);
Json(serde_json::json!({ "removed": removed, "errors": errors })) 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. /// `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<Arc<WebState>>,
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<Arc<WebState>>,
Path((platform, handle)): Path<(String, String)>,
Json(body): Json<crate::download_options::DownloadOptions>,
) -> 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<Arc<WebState>>,
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<Arc<WebState>>) -> impl IntoResponse { async fn post_scheduler_run(State(state): State<Arc<WebState>>) -> impl IntoResponse {
if state.downloader.lock().unwrap().any_running() { if state.downloader.lock().unwrap().any_running() {
return (StatusCode::CONFLICT, "downloads already running").into_response(); return (StatusCode::CONFLICT, "downloads already running").into_response();
} }
let urls: Vec<String> = state.library.lock().unwrap() // 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() .iter()
.map(|ch| crate::downloader::recheck_url(ch)) .map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect(); .collect();
if urls.is_empty() { if scheduled.is_empty() {
return (StatusCode::OK, "no channels to check").into_response(); return (StatusCode::OK, "no channels to check").into_response();
} }
let count = urls.len(); let count = scheduled.len();
let mut dl = state.downloader.lock().unwrap(); let mut dl = state.downloader.lock().unwrap();
for url in urls { for (url, opts) in scheduled {
let info = classify_url(&url); 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()); *state.last_scheduled_check.lock().unwrap() = Some(std::time::Instant::now());
(StatusCode::ACCEPTED, format!("started {count} channel checks")).into_response() (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(".")) .unwrap_or_else(|_| PathBuf::from("."))
.join("config.toml"); .join("config.toml");
let library = library::scan_channels(&channels_root); let mut library = library::scan_channels(&channels_root);
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"));
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 watched = db.get_watched().unwrap_or_default();
let positions = db.get_positions().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) last.map_or(true, |t| t.elapsed() >= interval_dur)
}; };
if !due { continue; } if !due { continue; }
let urls: Vec<String> = sched_state.library.lock().unwrap() let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
sched_state.library.lock().unwrap()
.iter() .iter()
.map(|ch| crate::downloader::recheck_url(ch)) .map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect(); .collect();
if urls.is_empty() { continue; } if scheduled.is_empty() { continue; }
let mut dl = sched_state.downloader.lock().unwrap(); let mut dl = sched_state.downloader.lock().unwrap();
for url in urls { for (url, opts) in scheduled {
let info = classify_url(&url); 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()); *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/maintenance/repair/:id", post(post_maintenance_repair))
.route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies)) .route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies))
.route("/api/scheduler/run", post(post_scheduler_run)) .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/stats", get(get_stats))
.route("/api/ytdlp/update", post(post_ytdlp_update)) .route("/api/ytdlp/update", post(post_ytdlp_update))
.route("/api/music", get(get_music)) .route("/api/music", get(get_music))

View file

@ -243,6 +243,7 @@ function renderSidebar(){
h+=`<div class="ch-sub${activePlaylistIdx===pi?' active':''}" onclick="setView(${i},${pi})">└ ${esc(pl.name)} (${pl.videos.length})</div>`; h+=`<div class="ch-sub${activePlaylistIdx===pi?' active':''}" onclick="setView(${i},${pi})">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
} }
h+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`; h+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
h+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
} }
} }
el.innerHTML=h; el.innerHTML=h;
@ -485,6 +486,102 @@ function rebuildChannelUrl(ch){
} }
async function downloadChannelByIdx(i){await downloadChannel(rebuildChannelUrl(library[i]))} async function downloadChannelByIdx(i){await downloadChannel(rebuildChannelUrl(library[i]))}
/* ── Per-channel download options ──────────────────────────────── */
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)=>`<option value="${val}"${q===val?' selected':''}>${label}</option>`;
const langs=(opts.subtitle_langs||[]).join(', ');
const extras=(opts.extra_args||[]).join('\n');
bg.innerHTML=`<div class="modal" style="max-width:480px;width:100%;overflow-y:auto;max-height:90vh">
<div class="modal-hdr"><h2>⚙ ${esc(ch.platform_label||ch.platform)} · ${esc(handle)}</h2>
<button onclick="this.closest('.modal-bg').remove()"></button></div>
<div class="settings-hint" style="margin-bottom:10px">Overrides apply during scheduled re-checks and "Check for new videos". Explicit downloads from the URL bar still use the dialog's own picker.</div>
<div class="settings-row"><label>Quality cap</label>
<select id="opt-quality" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 6px;border-radius:4px">
${qOpt('','— Global default —')}
${qOpt('Best','Best')}
${qOpt('Res1080','1080p')}
${qOpt('Res720','720p')}
${qOpt('Res480','480p')}
${qOpt('Res360','360p')}
</select></div>
<div class="settings-row"><label>Audio-only</label>
<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-row"><label>Bandwidth cap (KB/s)</label>
<input type="number" id="opt-rate" value="${opts.limit_rate_kb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Min size (MB)</label>
<input type="number" id="opt-minsz" value="${opts.min_filesize_mb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Max size (MB)</label>
<input type="number" id="opt-maxsz" value="${opts.max_filesize_mb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Only videos after (YYYYMMDD)</label>
<input type="text" id="opt-date" value="${esc(opts.date_after||'')}" placeholder="e.g. 20240101" pattern="[0-9]{8}" style="width:120px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Match filter (yt-dlp --match-filter)</label>
<input type="text" id="opt-filter" value="${esc(opts.match_filter||'')}" placeholder="e.g. duration > 60 & view_count > 100" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px;font-family:monospace;font-size:12px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Subtitle languages (comma separated, blank = all)</label>
<input type="text" id="opt-subs" value="${esc(langs)}" placeholder="en, ja" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Extra yt-dlp args (one per line)</label>
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
--ignore-config" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:12px">${esc(extras)}</textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button onclick="clearChannelOptions('${esc(platform)}','${esc(handle)}',this)">Clear all</button>
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="saveChannelOptions('${esc(platform)}','${esc(handle)}',this)">Save</button>
</div>
</div>`;
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 ─────────────────────────────────────────────────────── */ /* ── Rescan ─────────────────────────────────────────────────────── */
async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}} async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}