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:
parent
c713d05db8
commit
9335e0faa8
9 changed files with 855 additions and 35 deletions
312
src/app.rs
312
src/app.rs
|
|
@ -139,6 +139,30 @@ pub struct App {
|
|||
// Statistics window
|
||||
show_stats: bool,
|
||||
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 {
|
||||
|
|
@ -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<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) {
|
||||
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<String> = 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<usize> = 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);
|
||||
|
|
|
|||
|
|
@ -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<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>> {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||
|
|
|
|||
229
src/download_options.rs
Normal file
229
src/download_options.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Channel> {
|
|||
meta,
|
||||
total_videos_cached,
|
||||
total_size_cached,
|
||||
download_options: DownloadOptions::default(),
|
||||
})
|
||||
})
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
mod app;
|
||||
mod config;
|
||||
mod database;
|
||||
mod download_options;
|
||||
mod downloader;
|
||||
mod library;
|
||||
mod maintenance;
|
||||
|
|
|
|||
129
src/web.rs
129
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<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
|
||||
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<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 {
|
||||
if state.downloader.lock().unwrap().any_running() {
|
||||
return (StatusCode::CONFLICT, "downloads already running").into_response();
|
||||
}
|
||||
let urls: Vec<String> = 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<String> = 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))
|
||||
|
|
|
|||
|
|
@ -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" 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;
|
||||
|
|
@ -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)=>`<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 ─────────────────────────────────────────────────────── */
|
||||
async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue