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

View file

@ -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)}}