Deletion on and offline restored.

This commit is contained in:
2026-02-17 00:52:02 -06:00
parent 9ce31a6e09
commit 1242999b35
3 changed files with 186 additions and 2 deletions

2
README.md Normal file
View File

@@ -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.

View File

@@ -1227,3 +1227,138 @@ pub fn local_media_path_for_item_id(item_id: &str) -> Option<PathBuf> {
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<usize, String> {
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<usize, String> {
// 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 "<mediafilename>.cj-jftui.json" and returns item_id if present.
fn item_id_for_media_path(media_path: &Path) -> Result<Option<String>, 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(())
}

View File

@@ -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 were 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