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
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue