Per-folder "Check all" action + DB backup download

Two small follow-ups to the folder hierarchy that round out the
folder-as-a-profile workflow and give the user a one-click way to keep
an offsite copy of their library state.

Per-folder check
- New `POST /api/folders/:id/check` mirrors the existing
  /api/scheduler/run endpoint but scoped to one folder's members.
  Each channel's stored DownloadOptions (quality / rate limit /
  match-filter / extra args) applies just like a scheduled re-check.
  Returns 409 when downloads are already running.
- Web folder-manager modal: each non-empty folder gets a "⬇ Check"
  button next to Rename / Delete. Status line reports the count of
  channels queued.
- Desktop folder-manager window: same — a small ⬇ icon-button per row,
  visible only for folders with at least one member. Same options-
  honouring re-check path as the right-click "Check for new videos"
  action.
- This effectively gives us "Profiles" lite: organize channels into a
  folder, then trigger a batch re-check whenever you want.

DB backup
- New `GET /api/backup/db` reads `<channels_root>/yt-offline.db` and
  streams it back with `Content-Disposition: attachment` so the
  browser downloads instead of rendering. Filename includes the unix
  timestamp so downloads don't clobber. Returns 404 when running in
  in-memory mode (no DB file to dump).
- We deliberately don't bundle config.toml + cookies.txt here:
  config is short to recreate and cookies.txt carries live session
  credentials that shouldn't fly over the wire unprompted.
- Web settings modal gains a "⬇ Download library snapshot" button in
  a new Backup section, with a hint explaining what's covered (watched
  / favourites / bookmarks / waiting / channel-options / folders).

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-25 22:11:01 -07:00
parent 4b0e4b3b07
commit 9d414919cd
3 changed files with 107 additions and 1 deletions

View file

@ -690,6 +690,7 @@ async function openFolderManager(){
const memberCount=library.filter(c=>c.folder_id===f.id).length;
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<span style="flex:1">📁 ${esc(f.name)} <span style="color:var(--muted);font-size:11px">(${memberCount} channel${memberCount===1?'':'s'})</span></span>
${memberCount>0?`<button onclick="checkFolder(${f.id},'${esc(f.name)}',this)" title="Check every channel in this folder for new videos">⬇ Check</button>`:''}
<button onclick="renameFolderPrompt(${f.id},'${esc(f.name)}')">Rename</button>
<button onclick="deleteFolderConfirm(${f.id},'${esc(f.name)}',${memberCount})" style="color:#f87171">Delete</button>
</div>`;
@ -731,6 +732,16 @@ async function renameFolderPrompt(id,oldName){
openFolderManager();
}catch(e){setStatus('Error: '+e.message)}
}
async function checkFolder(id,name,btn){
btn.disabled=true;
try{
const r=await fetch(`/api/folders/${id}/check`,{method:'POST'});
const t=await r.text();
if(!r.ok&&r.status!==409)throw new Error(t);
setStatus(r.status===409?`${name}: downloads already running`:`${name}: ${t}`);
}catch(e){setStatus('Error: '+e.message)}
finally{btn.disabled=false}
}
async function deleteFolderConfirm(id,name,memberCount){
const msg=memberCount>0
? `Delete folder "${name}"? Its ${memberCount} channel${memberCount===1?'':'s'} will revert to "Unfiled".`
@ -1072,6 +1083,12 @@ async function openSettings(){
<span id="plex-status" style="font-size:11px;color:var(--muted)"></span>
</div>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Backup</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<div class="settings-hint">Saves the SQLite file holding your watched / favourites / bookmarks / waiting / channel-options / folders. Restore by replacing <code>yt-offline.db</code> in the channels directory.</div>
<button onclick="downloadBackupDb()">⬇ Download library snapshot</button>
</div>
${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
${logoutBtn}
@ -1113,6 +1130,12 @@ async function clearCookies(btn){
}catch(e){setStatus('Error: '+e.message)}finally{btn.disabled=false}
}
function downloadBackupDb(){
// Open in a new tab so the auth cookie rides along; the server sets
// Content-Disposition so the browser triggers a download instead of
// rendering the SQLite bytes inline.
window.open('/api/backup/db','_blank');
}
async function generatePlex(btn){
const path=document.getElementById('cf-plex-path')?.value.trim();
if(!path){document.getElementById('plex-status').textContent='Set a path first';return}