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

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());
}
}