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