From 1242999b354ce5822f6165705993e82cec19b291 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Tue, 17 Feb 2026 00:52:02 -0600 Subject: [PATCH] Deletion on and offline restored. --- README.md | 2 + src/core/downloads.rs | 135 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 51 +++++++++++++++- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..11bce84 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +This is the first actually useful programming project I have completed. +I still can't write in rust because of generative AI. \ No newline at end of file diff --git a/src/core/downloads.rs b/src/core/downloads.rs index 28227e8..09550c0 100644 --- a/src/core/downloads.rs +++ b/src/core/downloads.rs @@ -1227,3 +1227,138 @@ pub fn local_media_path_for_item_id(item_id: &str) -> Option { let root = downloads_root().ok()?; walk(&root, item_id) } + + +// Delete ALL local copies of an item by scanning *.cj-jftui.json markers recursively. +// Returns how many media files we attempted to remove (count of matched markers). +pub fn delete_local_by_item_id(item_id: &str) -> Result { + let root = downloads_root()?; + let mut deleted = 0usize; + delete_in_dir_by_item_id(&root, item_id, &mut deleted)?; + Ok(deleted) +} + +// Delete local content based on a concrete local file path. +// If there is a sibling marker, we read item_id and delete ALL copies (same as online behavior). +// If there is no marker, we delete just that file + sidecars. +pub fn delete_local_by_path(path: &Path) -> Result { + // If there is a sibling marker, prefer item_id-based deletion (removes hardlinks elsewhere too). + if let Some(item_id) = item_id_for_media_path(path)? { + return delete_local_by_item_id(&item_id); + } + + // No marker => delete just this file + any obvious sidecars. + delete_file_and_sidecars(path)?; + Ok(1) +} + +// -------------------- +// Internal helpers +// -------------------- + +fn delete_in_dir_by_item_id(dir: &Path, item_id: &str, deleted: &mut usize) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + + for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? { + let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path(); + + if p.is_dir() { + delete_in_dir_by_item_id(&p, item_id, deleted)?; + continue; + } + + // marker files only + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = match fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + + let v: Value = match serde_json::from_str(raw.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + + let mid = match v.get("item_id").and_then(|x| x.as_str()) { + Some(s) => s.trim(), + None => continue, + }; + + if mid != item_id { + continue; + } + + // If we matched the item_id, delete: + // - media file referenced by marker (if present) + // - marker itself + // - media sidecars (.aria2/.part) + if let Some(media_name) = v.get("media_file").and_then(|x| x.as_str()) { + let media_path = p + .parent() + .unwrap_or(dir) + .join(media_name); + + let _ = delete_file_and_sidecars(&media_path); + } + + let _ = fs::remove_file(&p); // remove marker + *deleted += 1; + } + + Ok(()) +} + +// Reads sibling marker ".cj-jftui.json" and returns item_id if present. +fn item_id_for_media_path(media_path: &Path) -> Result, String> { + let fname = match media_path.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => return Ok(None), + }; + + let marker = media_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{fname}.{DOWNLOAD_MARKER_EXT}")); + + if !marker.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(&marker).map_err(|e| format!("read marker failed: {e}"))?; + let v: Value = serde_json::from_str(raw.trim()).map_err(|e| format!("marker json parse failed: {e}"))?; + + Ok(v.get("item_id").and_then(|x| x.as_str()).map(|s| s.to_string())) +} + +fn delete_file_and_sidecars(path: &Path) -> Result<(), String> { + // delete main file + let _ = fs::remove_file(path); + + // delete sibling marker (if any) + if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { + let marker = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{fname}.{DOWNLOAD_MARKER_EXT}")); + let _ = fs::remove_file(marker); + } + + // delete obvious aria2 artifacts + if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + + let aria2 = parent.join(format!("{fname}.aria2")); + let part = parent.join(format!("{fname}.part")); + + let _ = fs::remove_file(aria2); + let _ = fs::remove_file(part); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 26e4fc5..3fa0dee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -668,6 +668,54 @@ fn handle_key(app: &mut App, code: KeyCode) { } } + // Capital X: delete local copy/copies for hovered item (works same online/offline) + if matches!(code, KeyCode::Char('X')) { + let result = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let id = items[*selected].id.clone(); + crate::core::downloads::delete_local_by_item_id(&id) + } + View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { + let e = &entries[*selected]; + if e.is_dir { + app.status = "X: select a file (not a folder)".into(); + return; + } + crate::core::downloads::delete_local_by_path(&e.path) + } + _ => { + app.status = "X: nothing deletable here".into(); + return; + } + }; + + match result { + Ok(n) => { + // refresh downloaded-id index so 💾 updates immediately + app.downloaded_ids = crate::core::downloads::scan_downloaded_ids().unwrap_or_default(); + + // if we’re in OfflineLibrary, refresh the directory listing so the file disappears + if let Some(View::OfflineLibrary { dir, entries, selected, .. }) = app.view_stack.last_mut() { + *entries = offline_list_dir(dir); + if !entries.is_empty() { + *selected = (*selected).min(entries.len() - 1); + } else { + *selected = 0; + } + } + + app.status = format!("Deleted local copies: {n}"); + } + Err(e) => { + app.status = format!("Delete failed: {e}"); + } + } + + return; + } + + + // Mark watched (w) / Unmark watched (W) if matches!(code, KeyCode::Char('w') | KeyCode::Char('W')) { let unmark = matches!(code, KeyCode::Char('W')); @@ -1199,7 +1247,6 @@ fn handle_key(app: &mut App, code: KeyCode) { return; } - KeyCode::Char('q') => app.should_exit = true, //Enter code Block @@ -1903,7 +1950,7 @@ fn ui(frame: &mut Frame, app: &App) { .unwrap_or("-"); let right_text = format!( - "{connected}\n\np/P: play/autoplay + "{connected}\np/P: play/autoplay r/R: resume/autoplay d: download / cancel q: quit