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:
parent
4b0e4b3b07
commit
9d414919cd
3 changed files with 107 additions and 1 deletions
25
src/app.rs
25
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<i64> = 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) {
|
||||
|
|
|
|||
60
src/web.rs
60
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<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
|
||||
/// 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))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue