Deletion on and offline restored.
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
51
src/main.rs
51
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
|
||||
|
||||
Reference in New Issue
Block a user