diff --git a/src/app.rs b/src/app.rs index 7636d67..7e8ba1d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1762,6 +1762,7 @@ impl App { let mut create_clicked = false; let mut to_rename: Option<(i64, String)> = None; let mut to_delete: Option<(i64, String, usize)> = None; + let mut to_check: Option = None; egui::Window::new("📁 Manage folders") .open(&mut open) .collapsible(false) @@ -1782,9 +1783,14 @@ impl App { if ui.small_button("🗑").on_hover_text("Delete folder").clicked() { 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())); } + 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(); @@ -1830,6 +1836,23 @@ impl App { 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 { // Hard delete — member channels revert to Unfiled. if let Err(e) = self.db.delete_folder(id) { diff --git a/src/web.rs b/src/web.rs index 3d9639a..adf4d1c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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>) -> 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>, + Path(folder_id): Path, +) -> 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 /// folder, or pass `null` to clear (back to "Unfiled"). 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/folders", post(post_create_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/channels/:platform/:handle/folder", post(post_assign_folder)) .route("/api/resume/:id", post(post_resume)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index b09917a..07149ec 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -690,6 +690,7 @@ async function openFolderManager(){ const memberCount=library.filter(c=>c.folder_id===f.id).length; return `
📁 ${esc(f.name)} (${memberCount} channel${memberCount===1?'':'s'}) + ${memberCount>0?``:''}
`; @@ -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(){ +
+
Backup
+
+
Saves the SQLite file holding your watched / favourites / bookmarks / waiting / channel-options / folders. Restore by replacing yt-offline.db in the channels directory.
+ +
${srcRow}
${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}