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