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