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

@ -1762,6 +1762,7 @@ impl App {
let mut create_clicked = false; let mut create_clicked = false;
let mut to_rename: Option<(i64, String)> = None; let mut to_rename: Option<(i64, String)> = None;
let mut to_delete: Option<(i64, String, usize)> = None; let mut to_delete: Option<(i64, String, usize)> = None;
let mut to_check: Option<i64> = None;
egui::Window::new("📁 Manage folders") egui::Window::new("📁 Manage folders")
.open(&mut open) .open(&mut open)
.collapsible(false) .collapsible(false)
@ -1782,9 +1783,14 @@ impl App {
if ui.small_button("🗑").on_hover_text("Delete folder").clicked() { if ui.small_button("🗑").on_hover_text("Delete folder").clicked() {
to_delete = Some((f.id, f.name.clone(), member_count)); to_delete = Some((f.id, f.name.clone(), member_count));
} }
if ui.small_button("").on_hover_text("Rename folder").clicked() { if ui.small_button("").on_hover_text("Rename (type new name in field below first)").clicked() {
to_rename = Some((f.id, f.name.clone())); to_rename = Some((f.id, f.name.clone()));
} }
if member_count > 0
&& ui.small_button("").on_hover_text("Check every channel in this folder for new videos").clicked()
{
to_check = Some(f.id);
}
}); });
}); });
ui.separator(); ui.separator();
@ -1830,6 +1836,23 @@ impl App {
self.status = format!("Type new name into the field below, then click ✏ on '{old_name}' again"); self.status = format!("Type new name into the field below, then click ✏ on '{old_name}' again");
} }
} }
if let Some(folder_id) = to_check {
// Check every member channel for new videos, applying that
// channel's own DownloadOptions / quality override just like
// a scheduled re-check would.
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
self.library.iter()
.filter(|ch| ch.folder_id == Some(folder_id))
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect();
let count = scheduled.len();
for (url, opts) in scheduled {
let info = classify_url(&url);
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
self.downloader.start(url, &info, true, quality, false, Some(&opts));
}
self.status = format!("Folder check: started {count} channel download{}", if count == 1 { "" } else { "s" });
}
if let Some((id, name, count)) = to_delete { if let Some((id, name, count)) = to_delete {
// Hard delete — member channels revert to Unfiled. // Hard delete — member channels revert to Unfiled.
if let Err(e) = self.db.delete_folder(id) { if let Err(e) = self.db.delete_folder(id) {

View file

@ -1681,6 +1681,64 @@ async fn delete_folder(
} }
} }
/// `GET /api/backup/db` — stream the SQLite database file back as an
/// attachment so the user can keep an offsite copy of their watched /
/// flag / channel-options / folder state.
///
/// We intentionally don't snapshot config.toml or cookies.txt here:
/// config is short and easy to recreate, cookies.txt contains live
/// session credentials that shouldn't fly over the wire unprompted, and
/// keeping the endpoint to one file means no extra deps for tar/zip.
async fn get_backup_db(State(state): State<Arc<WebState>>) -> Response {
let db_path = state.channels_root.join("yt-offline.db");
let Ok(bytes) = std::fs::read(&db_path) else {
return (StatusCode::NOT_FOUND, "no db file on disk (running in-memory?)").into_response();
};
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let filename = format!("yt-offline-{now_secs}.db");
(
[
(header::CONTENT_TYPE, "application/x-sqlite3".to_string()),
(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\"")),
(header::CACHE_CONTROL, "no-store".to_string()),
],
bytes,
).into_response()
}
/// `POST /api/folders/:id/check` — fire a re-check on every channel in
/// the folder. Mirrors `POST /api/scheduler/run` but scoped to a single
/// folder's members. Each channel's stored download_options + quality
/// override apply as if the user had hit "Check for new videos" by hand.
async fn post_check_folder(
State(state): State<Arc<WebState>>,
Path(folder_id): Path<i64>,
) -> impl IntoResponse {
if state.downloader.lock().unwrap().any_running() {
return (StatusCode::CONFLICT, "downloads already running").into_response();
}
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
state.library.lock().unwrap()
.iter()
.filter(|ch| ch.folder_id == Some(folder_id))
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect();
if scheduled.is_empty() {
return (StatusCode::OK, "no channels in folder").into_response();
}
let count = scheduled.len();
let mut dl = state.downloader.lock().unwrap();
for (url, opts) in scheduled {
let info = classify_url(&url);
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
dl.start(url, &info, true, quality, false, Some(&opts));
}
(StatusCode::ACCEPTED, format!("started {count} channel checks")).into_response()
}
/// `POST /api/channels/:platform/:handle/folder` — move a channel into a /// `POST /api/channels/:platform/:handle/folder` — move a channel into a
/// folder, or pass `null` to clear (back to "Unfiled"). /// folder, or pass `null` to clear (back to "Unfiled").
async fn post_assign_folder( async fn post_assign_folder(
@ -2028,6 +2086,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/videos/:id/flags/:flag", post(post_video_flag)) .route("/api/videos/:id/flags/:flag", post(post_video_flag))
.route("/api/folders", post(post_create_folder)) .route("/api/folders", post(post_create_folder))
.route("/api/folders/:id/rename", post(post_rename_folder)) .route("/api/folders/:id/rename", post(post_rename_folder))
.route("/api/folders/:id/check", post(post_check_folder))
.route("/api/backup/db", get(get_backup_db))
.route("/api/folders/:id", axum::routing::delete(delete_folder)) .route("/api/folders/:id", axum::routing::delete(delete_folder))
.route("/api/channels/:platform/:handle/folder", post(post_assign_folder)) .route("/api/channels/:platform/:handle/folder", post(post_assign_folder))
.route("/api/resume/:id", post(post_resume)) .route("/api/resume/:id", post(post_resume))

View file

@ -690,6 +690,7 @@ async function openFolderManager(){
const memberCount=library.filter(c=>c.folder_id===f.id).length; 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)"> 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> <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="renameFolderPrompt(${f.id},'${esc(f.name)}')">Rename</button>
<button onclick="deleteFolderConfirm(${f.id},'${esc(f.name)}',${memberCount})" style="color:#f87171">Delete</button> <button onclick="deleteFolderConfirm(${f.id},'${esc(f.name)}',${memberCount})" style="color:#f87171">Delete</button>
</div>`; </div>`;
@ -731,6 +732,16 @@ async function renameFolderPrompt(id,oldName){
openFolderManager(); openFolderManager();
}catch(e){setStatus('Error: '+e.message)} }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){ async function deleteFolderConfirm(id,name,memberCount){
const msg=memberCount>0 const msg=memberCount>0
? `Delete folder "${name}"? Its ${memberCount} channel${memberCount===1?'':'s'} will revert to "Unfiled".` ? `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> <span id="plex-status" style="font-size:11px;color:var(--muted)"></span>
</div> </div>
</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} ${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px"> <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
${logoutBtn} ${logoutBtn}
@ -1113,6 +1130,12 @@ async function clearCookies(btn){
}catch(e){setStatus('Error: '+e.message)}finally{btn.disabled=false} }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){ async function generatePlex(btn){
const path=document.getElementById('cf-plex-path')?.value.trim(); const path=document.getElementById('cf-plex-path')?.value.trim();
if(!path){document.getElementById('plex-status').textContent='Set a path first';return} if(!path){document.getElementById('plex-status').textContent='Set a path first';return}