Delete captures from disk at any depth, behind a confirmation

Deletion (d) previously reached only top-level folders of editable
stores. /captures now exposes its whole tree: nested folders delete
recursively, single tracks delete their metadata file plus the
downloaded audio next to it (never audio outside the instance root).
Tracks advertise this through the new LibraryNode.tracks_deletable
flag. Because these deletes destroy slow-to-redo downloads, the TUI
asks delete <title>? [y/N] first; cheap deletables (search terms,
bookmarks, saved queues) stay unconfirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-21 20:34:03 +02:00
parent 383173046d
commit 1c83217745
14 changed files with 612 additions and 38 deletions

View File

@ -84,7 +84,9 @@ renames, `d` deletes.
re-capturing the same name resumes and completes it; tracks whose re-capturing the same name resumes and completes it; tracks whose
source cannot be captured are recorded as *skipped* (red in the UI, source cannot be captured are recorded as *skipped* (red in the UI,
skipped by playback). Download captures can take long; progress is skipped by playback). Download captures can take long; progress is
shown in the library pane. shown in the library pane. Inside `/captures`, `d` deletes any
folder or single track *from disk* (audio included) after a `y/N`
confirmation.
Press `?` for the full binding table. Press `?` for the full binding table.

View File

@ -0,0 +1,98 @@
# Capture deletion
Deleting under `/captures` reclaims disk: downloaded audio is the one
library content that is expensive to recreate (slow, throttled downloads
— architecture/youtube-rustypipe.md), so stale captures must be
removable from the TUI, and removal must actually delete the files.
## Context
Before this feature, deletion (`d`) was limited to *top-level* folders
of editable fsdy instances (architecture/bookmarks.md D4): whole
captures could be deleted (and were removed from disk via
`remove_dir_all`), but nothing below — no single album, no single
track. Deletes were deliberately unconfirmed because every deletable
node was cheap to recreate.
## Decisions
### D1 — deletable tree as an fsdy instance option
`fsdy::Client::with_deletable_tree()` makes every folder below the
instance root deletable (recursively, any depth) and every track file
deletable. Only the `/captures` instance sets it:
- `/queues` and `/bookmarks` keep the top-level-only contract; their
nested structure mirrors a snapshot and partial edits are better done
by re-saving.
- Nested folders become deletable but **not renamable** — renames would
break the incremental-capture merge by name
(architecture/incremental-captures.md), deletes cannot: a re-capture
of the same name simply re-downloads what is missing.
- The instance root itself and reserved top-level names stay
undeletable even on a deletable tree.
### D2 — tracks advertise deletability through their node
Wire truth, not client guessing: the TUI must not hardcode which tracks
are deletable. But a per-`Track` flag would touch every Track literal
in every provider for a capability only fsdy uses. Instead
`LibraryNode.tracks_deletable` says "this node's listed tracks may be
deleted", exactly like the existing `is_downloadable` inheritance
("tracks inherit their node's blessing", architecture/captures.md D4).
Child folders keep using the existing per-child `is_deletable`.
### D3 — a deleted track takes its audio with it, inside the root only
Deleting a track file removes the `.cbd-track.toml` **and** the audio
its `[playable] file` points to — that is the point of the feature.
Safety boundary: the audio path (relative values resolved against the
track file's directory) is canonicalized and must live inside the
canonicalized instance root; anything else is kept and logged. So a
hand-written track file referencing `~/Music/song.flac` from inside the
captures folder can never delete foreign data, and `..`/symlink tricks
resolve before the check. A track file that no longer parses is deleted
blind (its audio cannot be located; the listing skipped it anyway).
Deletes stay idempotent per the proto contract.
### D4 — confirmation in the client, scoped to /captures
`d` on anything under `/captures` opens a one-line modal prompt
(`delete <title>? [y/N]`, red) instead of sending; only `y`/`Y`
confirms, any other key cancels. Every other deletable (search terms,
bookmarks, saved queues) stays a single unconfirmed keypress
(architecture/node-editing.md D4) — they are cheap to recreate, and a
blanket confirmation would train reflexive `y`. The scoping is a path
check in the TUI (like the `/captures` never-cache rule in
`cbd-tui/src/rpc.rs`): the server does not know which deletes a client
should consider expensive.
## Flow
```d2
direction: right
tui: cbd-tui {
d: "d on /captures/…"
confirm: "delete …? [y/N]"
d -> confirm
}
server: crabidy-server {
provider_loop: provider loop
}
fsdy: fsdy /captures instance {
folder: "folder: remove_dir_all"
track: "track: toml + contained audio"
}
tui.confirm -> server.provider_loop: y → DeleteLibraryNode
server.provider_loop -> fsdy.folder
server.provider_loop -> fsdy.track
fsdy.folder -> tui: refreshed parent listing
```
## Risks / notes
- The confirmation prompt occupies the same line as the text-input
overlay; both are strictly modal and never open together.
- Deleting the folder of a *running* capture is possible; the capture
walk recreates directories as it goes and re-downloads on the next
run, so the race wastes bandwidth but corrupts nothing.

View File

@ -65,8 +65,10 @@ pub enum Action {
/// term, whose rename re-runs the search). /// term, whose rename re-runs the search).
LibraryEditNode, LibraryEditNode,
/// Delete the selected item. No-op unless the selection `is_deletable`. /// Delete the selected item. No-op unless the selection `is_deletable`.
/// Deliberately unconfirmed: today's deletable nodes (search terms) are /// Cheap deletables (search terms, bookmarks, saved queues) delete
/// free to recreate (architecture/node-editing.md, D4). /// unconfirmed (architecture/node-editing.md, D4); captures hold
/// downloaded audio and open a y/N confirmation instead
/// (architecture/capture-deletion.md).
LibraryDeleteNode, LibraryDeleteNode,
/// Open the input overlay (prefilled with the selection's title) to /// Open the input overlay (prefilled with the selection's title) to
/// capture the selected queueable subtree as a bookmark under /// capture the selected queueable subtree as a bookmark under
@ -296,7 +298,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::NONE, mods: KeyModifiers::NONE,
code: KeyCode::Char('d'), code: KeyCode::Char('d'),
action: Action::LibraryDeleteNode, action: Action::LibraryDeleteNode,
description: "Delete selected node", description: "Delete selection (captures ask y/N, and delete files)",
}, },
Binding { Binding {
scope: Scope::Library, scope: Scope::Library,

View File

@ -60,11 +60,12 @@ impl Library {
item.is_editable item.is_editable
.then(|| (item.path.clone(), item.title.clone())) .then(|| (item.path.clone(), item.title.clone()))
} }
/// Path of the selected item, if it may be deleted (`d`). `None` when /// Path and title of the selected item, if it may be deleted (`d`).
/// nothing is selected or the item is not deletable. /// `None` when nothing is selected or the item is not deletable.
pub fn selected_deletable(&self) -> Option<String> { pub fn selected_deletable(&self) -> Option<(String, String)> {
let item = self.list.get(self.list_state.selected()?)?; let item = self.list.get(self.list_state.selected()?)?;
item.is_deletable.then(|| item.path.clone()) item.is_deletable
.then(|| (item.path.clone(), item.title.clone()))
} }
/// Path and title of the bare selection, if it is queueable — what `w` /// Path and title of the bare selection, if it is queueable — what `w`
/// captures as a bookmark. Marks are deliberately ignored: one capture /// captures as a bookmark. Marks are deliberately ignored: one capture
@ -194,9 +195,9 @@ impl Library {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_editable: false, is_editable: false,
is_deletable: false, // Tracks carry no wire flags of their own: they inherit
// Tracks carry no wire flag: they inherit their node's // their node's blessing (architecture/captures.md D4).
// blessing (architecture/captures.md D4). is_deletable: node.tracks_deletable,
is_downloadable: node.is_downloadable, is_downloadable: node.is_downloadable,
is_skipped: t.is_skipped, is_skipped: t.is_skipped,
}) })

View File

@ -242,6 +242,24 @@ pub struct InputState {
pub buffer: String, pub buffer: String,
} }
/// A delete waiting for its `y` — opened instead of sending when the
/// selection is a capture (`d` there destroys downloaded data on disk,
/// architecture/capture-deletion.md). Modal like [`InputState`]: while
/// `Some`, keys go to [`App::handle_confirm_key`] only.
pub struct ConfirmDelete {
pub path: String,
/// Display title of the doomed item, shown in the prompt.
pub title: String,
}
/// Whether deleting `path` needs a confirmation: deletes under
/// `/captures` remove downloaded audio from disk — expensive to redo —
/// while every other deletable node (search terms, bookmarks, saved
/// queues) is cheap to recreate and stays one keypress.
fn delete_needs_confirmation(path: &str) -> bool {
path == "/captures" || path.starts_with("/captures/")
}
pub struct App { pub struct App {
pub focus: UiFocus, pub focus: UiFocus,
/// Whether the help modal is open. While true, `bindings::lookup` only /// Whether the help modal is open. While true, `bindings::lookup` only
@ -250,6 +268,9 @@ pub struct App {
/// `Some` while the input overlay is open; takes precedence over the /// `Some` while the input overlay is open; takes precedence over the
/// bindings table (checked first in the event loop). /// bindings table (checked first in the event loop).
pub input: Option<InputState>, pub input: Option<InputState>,
/// `Some` while a capture delete awaits confirmation; modal like
/// `input` and checked before it in the event loop.
pub confirm: Option<ConfirmDelete>,
/// Progress of running (and recently finished) captures, rendered as /// Progress of running (and recently finished) captures, rendered as
/// status lines at the bottom of the library pane. /// status lines at the bottom of the library pane.
pub captures: CaptureBoard, pub captures: CaptureBoard,
@ -268,6 +289,7 @@ impl App {
focus: UiFocus::Library, focus: UiFocus::Library,
show_help: false, show_help: false,
input: None, input: None,
confirm: None,
captures: CaptureBoard::default(), captures: CaptureBoard::default(),
library, library,
now_playing, now_playing,
@ -276,6 +298,21 @@ impl App {
} }
} }
/// Handles one key while a delete confirmation is open
/// (`confirm.is_some()`). Only `y`/`Y` confirms and sends the delete;
/// every other key cancels — the safe answer is any answer.
pub fn handle_confirm_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
let Some(confirm) = self.confirm.take() else {
return;
};
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let _ = self
.tx
.send(MessageFromUi::DeleteNode { path: confirm.path });
}
}
/// Handles one key while the input overlay is open (`input.is_some()`). /// Handles one key while the input overlay is open (`input.is_some()`).
/// ///
/// `Esc` cancels, `Enter` submits a non-empty trimmed buffer as the /// `Esc` cancels, `Enter` submits a non-empty trimmed buffer as the
@ -399,10 +436,16 @@ impl App {
} }
} }
Action::LibraryDeleteNode => { Action::LibraryDeleteNode => {
// Unconfirmed by design: today's deletable nodes are search // Cheap deletables (search terms, bookmarks, saved queues)
// terms, free to recreate (architecture/node-editing.md, D4). // stay unconfirmed by design (architecture/node-editing.md,
if let Some(path) = self.library.selected_deletable() { // D4); captures hold downloaded audio and get a y/N prompt
let _ = self.tx.send(MessageFromUi::DeleteNode { path }); // first (architecture/capture-deletion.md).
if let Some((path, title)) = self.library.selected_deletable() {
if delete_needs_confirmation(&path) {
self.confirm = Some(ConfirmDelete { path, title });
} else {
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
}
} }
} }
Action::LibraryCaptureNode => { Action::LibraryCaptureNode => {
@ -521,6 +564,22 @@ impl App {
} }
} }
// The capture-delete confirmation: same one-line slot as the text
// input (the two are never open together), red — it is the one
// destructive prompt in the UI.
if let Some(confirm) = &self.confirm {
let area = main[0];
if area.height >= 3 && area.width >= 4 {
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
f.render_widget(Clear, line);
f.render_widget(
Paragraph::new(format!("delete {}? [y/N]", confirm.title))
.style(Style::default().fg(COLOR_RED)),
line,
);
}
}
// Capture progress lines: stacked up from the bottom of the library // Capture progress lines: stacked up from the bottom of the library
// pane, above the input overlay when that is open. Failures render // pane, above the input overlay when that is open. Failures render
// red. At most three — more concurrent captures than that keep // red. At most three — more concurrent captures than that keep
@ -528,7 +587,11 @@ impl App {
let capture_lines = self.captures.lines(); let capture_lines = self.captures.lines();
if !capture_lines.is_empty() { if !capture_lines.is_empty() {
let area = main[0]; let area = main[0];
let bottom_offset = if self.input.is_some() { 3 } else { 2 }; let bottom_offset = if self.input.is_some() || self.confirm.is_some() {
3
} else {
2
};
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() { for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
let offset = bottom_offset + i as u16; let offset = bottom_offset + i as u16;
if area.height <= offset + 1 || area.width < 4 { if area.height <= offset + 1 || area.width < 4 {
@ -635,6 +698,7 @@ mod tests {
is_queable: false, is_queable: false,
is_creatable: true, is_creatable: true,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
@ -885,6 +949,104 @@ mod tests {
} }
other => panic!("expected DeleteNode, got {:?}", other.is_ok()), other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
} }
assert!(app.confirm.is_none(), "cheap deletes are unconfirmed");
}
/// A /captures/mix listing: one deletable track (tracks come first in
/// the pane) and one deletable album folder.
fn captures_listing() -> LibraryNode {
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
LibraryNode {
path: "/captures/mix".to_string(),
title: "mix".to_string(),
children: vec![LibraryNodeChild {
is_deletable: true,
..LibraryNodeChild::new(
"/captures/mix/album".to_string(),
"album".to_string(),
true,
)
}],
parent: Some("/captures".to_string()),
tracks: vec![Track {
path: "/captures/mix/0001%20song.cbd-track.toml".to_string(),
artist: "artist".to_string(),
title: "song".to_string(),
duration: None,
album: None,
is_skipped: false,
}],
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: true,
}
}
#[test]
fn capture_deletes_open_a_confirmation_instead_of_sending() {
let (mut app, rx) = app();
app.library.update(captures_listing());
// The first item is the capture's track, deletable via the node's
// tracks_deletable flag.
let _ = app.dispatch(Action::LibraryDeleteNode);
assert!(rx.try_recv().is_err(), "nothing sent before the y");
let confirm = app.confirm.as_ref().expect("confirmation open");
assert_eq!(confirm.path, "/captures/mix/0001%20song.cbd-track.toml");
assert_eq!(confirm.title, "artist - song");
app.handle_confirm_key(key(crossterm::event::KeyCode::Char('y')));
assert!(app.confirm.is_none());
match rx.try_recv() {
Ok(MessageFromUi::DeleteNode { path }) => {
assert_eq!(path, "/captures/mix/0001%20song.cbd-track.toml");
}
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
}
// Folders below the top level are deletable (and confirmed) too.
let mut app_state = app;
app_state.library.update(captures_listing());
app_state.library.last();
let _ = app_state.dispatch(Action::LibraryDeleteNode);
let confirm = app_state.confirm.as_ref().expect("confirmation open");
assert_eq!(confirm.path, "/captures/mix/album");
}
#[test]
fn capture_delete_confirmations_cancel_on_anything_but_y() {
use crossterm::event::KeyCode;
let (mut app, rx) = app();
for cancel in [KeyCode::Esc, KeyCode::Char('n'), KeyCode::Enter] {
app.library.update(captures_listing());
let _ = app.dispatch(Action::LibraryDeleteNode);
assert!(app.confirm.is_some(), "confirmation open");
app.handle_confirm_key(key(cancel));
assert!(app.confirm.is_none(), "{cancel:?} closes");
assert!(rx.try_recv().is_err(), "{cancel:?} must not delete");
}
}
#[test]
fn capture_delete_confirmation_renders_its_prompt() {
let (mut app, _rx) = app();
app.library.update(captures_listing());
let _ = app.dispatch(Action::LibraryDeleteNode);
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
terminal.draw(|f| app.render(f)).expect("draw app");
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
assert!(
text.contains("delete artist - song? [y/N]"),
"prompt line visible"
);
} }
#[test] #[test]

View File

@ -277,10 +277,12 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
if event::poll(timeout).unwrap() { if event::poll(timeout).unwrap() {
if let Event::Key(key) = event::read().unwrap() { if let Event::Key(key) = event::read().unwrap() {
if key.kind == KeyEventKind::Press { if key.kind == KeyEventKind::Press {
// The input overlay is strictly modal: while it is open, // The overlays are strictly modal: while one is open,
// keys edit the buffer and the bindings table (including // keys answer it and the bindings table (including
// quit) is unreachable. // quit) is unreachable.
if app.input.is_some() { if app.confirm.is_some() {
app.handle_confirm_key(key);
} else if app.input.is_some() {
app.handle_input_key(key); app.handle_input_key(key);
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) { } else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
if app.dispatch(action) == DispatchResult::Quit { if app.dispatch(action) == DispatchResult::Quit {

View File

@ -17,7 +17,10 @@ service CrabidyService {
// changes with the title. Renaming onto an existing sibling title merges // changes with the title. Renaming onto an existing sibling title merges
// with it. Returns the renamed node at its new path. // with it. Returns the renamed node at its new path.
rpc RenameLibraryNode(RenameLibraryNodeRequest) returns (RenameLibraryNodeResponse); rpc RenameLibraryNode(RenameLibraryNodeRequest) returns (RenameLibraryNodeResponse);
// Deletes a node whose listing entry sets is_deletable. Idempotent: // Deletes a node whose listing entry sets is_deletable, or a track
// whose parent node sets tracks_deletable. On filesystem-backed stores
// this removes the data from disk: a folder is deleted recursively, a
// track loses its metadata file and its local audio. Idempotent:
// deleting an already-gone node succeeds. Returns the refreshed parent. // deleting an already-gone node succeeds. Returns the refreshed parent.
rpc DeleteLibraryNode(DeleteLibraryNodeRequest) returns (DeleteLibraryNodeResponse); rpc DeleteLibraryNode(DeleteLibraryNodeRequest) returns (DeleteLibraryNodeResponse);
// Captures the queueable subtree at `path` as the bookmark `name`: a // Captures the queueable subtree at `path` as the bookmark `name`: a
@ -307,4 +310,7 @@ message LibraryNode {
// This node allows download captures; its listed tracks inherit the // This node allows download captures; its listed tracks inherit the
// flag (CaptureLibraryNode with download). // flag (CaptureLibraryNode with download).
bool is_downloadable = 8; bool is_downloadable = 8;
// This node's listed tracks may be deleted (see DeleteLibraryNode)
// like is_downloadable, tracks inherit the node's flag.
bool tracks_deletable = 9;
} }

View File

@ -210,6 +210,7 @@ impl LibraryNode {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
} }
@ -366,6 +367,7 @@ mod tests {
is_queable, is_queable,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }

View File

@ -360,7 +360,13 @@ impl ProviderClient for ProviderOrchestrator {
}; };
let captures_client = capture_store.as_ref().and_then(|store| { let captures_client = capture_store.as_ref().and_then(|store| {
match fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf()) { match fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf()) {
Ok(client) => Some(Arc::new(client.with_editable_top_level(&[]))), // The whole tree is deletable: stale downloads are
// reclaimed through the library, folders recursively and
// tracks together with their audio. Clients confirm these
// deletes (architecture/capture-deletion.md).
Ok(client) => Some(Arc::new(
client.with_editable_top_level(&[]).with_deletable_tree(),
)),
Err(err) => { Err(err) => {
warn!("captures library disabled: {err}"); warn!("captures library disabled: {err}");
None None

View File

@ -59,3 +59,9 @@ manager — they are just folders of the track files described above.
Their top-level folders can be renamed (`e`) and deleted (`d`) from the Their top-level folders can be renamed (`e`) and deleted (`d`) from the
TUI; `/queues/current` is the continuously persisted play queue and is TUI; `/queues/current` is the continuously persisted play queue and is
protected. protected.
`/captures` additionally lets `d` delete *anything* in its tree — a
whole capture, a nested album folder, or a single track (which takes
its downloaded audio file with it). Because that removes data from
disk that was slow to download, the TUI asks for confirmation first
(`delete <name>? [y/N]`); deletes elsewhere stay unconfirmed.

View File

@ -375,6 +375,12 @@ pub struct Client {
/// downloadable providers; off for `/fs` and `/captures`. Tracks /// downloadable providers; off for `/fs` and `/captures`. Tracks
/// whose source cannot be captured are skipped by the capture walk. /// whose source cannot be captured are skipped by the capture walk.
downloadable_nodes: bool, downloadable_nodes: bool,
/// Whether the whole tree is deletable through the library: every
/// folder below the instance root and every track file (which loses
/// its local audio along with its metadata). On for `/captures`
/// only — deletes there destroy downloaded data, so clients confirm
/// them (architecture/capture-deletion.md).
deletable_tree: bool,
} }
impl Client { impl Client {
@ -405,6 +411,7 @@ impl Client {
editable_top_level: false, editable_top_level: false,
reserved: Vec::new(), reserved: Vec::new(),
downloadable_nodes: false, downloadable_nodes: false,
deletable_tree: false,
}) })
} }
@ -430,6 +437,18 @@ impl Client {
self self
} }
/// Makes the whole tree deletable through the library
/// ([`ProviderClient::delete_lib_node`]): every folder below the
/// instance root (recursively, any depth) and every track file. A
/// deleted track loses its metadata file *and* its local audio, as
/// long as the audio resolves inside the instance root. Used by
/// `/captures`, where stale downloads are reclaimed through the
/// library (architecture/capture-deletion.md).
pub fn with_deletable_tree(mut self) -> Self {
self.deletable_tree = true;
self
}
/// The provider root this instance owns (e.g. `/fs`), as configured /// The provider root this instance owns (e.g. `/fs`), as configured
/// through [`Self::new`]. /// through [`Self::new`].
pub fn provider_root(&self) -> &str { pub fn provider_root(&self) -> &str {
@ -538,7 +557,8 @@ impl Client {
} }
// Only an editable instance's direct root children are modifiable // Only an editable instance's direct root children are modifiable
// (architecture/bookmarks.md D4); reserved names (the auto-persisted // (architecture/bookmarks.md D4); reserved names (the auto-persisted
// `current` queue) keep their immutability. // `current` queue) keep their immutability. A deletable tree
// additionally makes every folder deletable (but not renamable).
let editable_here = self.editable_top_level && lib_path == self.provider_root; let editable_here = self.editable_top_level && lib_path == self.provider_root;
let children = dir_names let children = dir_names
.into_iter() .into_iter()
@ -548,7 +568,7 @@ impl Client {
let editable = editable_here && !self.reserved.contains(&name); let editable = editable_here && !self.reserved.contains(&name);
let mut child = LibraryNodeChild::new(child_lib, name, true); let mut child = LibraryNodeChild::new(child_lib, name, true);
child.is_editable = editable; child.is_editable = editable;
child.is_deletable = editable; child.is_deletable = editable || self.deletable_tree;
child.is_downloadable = self.downloadable_nodes; child.is_downloadable = self.downloadable_nodes;
child child
}) })
@ -571,6 +591,7 @@ impl Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: self.downloadable_nodes, is_downloadable: self.downloadable_nodes,
tracks_deletable: self.deletable_tree,
}) })
} }
@ -599,6 +620,78 @@ impl Client {
Ok((self.disk_path(path)?, name)) Ok((self.disk_path(path)?, name))
} }
/// Whether `path` addresses a reserved direct child of the instance
/// root (e.g. the auto-persisted `current` queue) — immutable even on
/// a deletable tree.
fn is_reserved_top_level(&self, path: &str) -> bool {
path.strip_prefix(self.provider_root.as_str())
.and_then(|rest| rest.strip_prefix('/'))
.is_some_and(|rest| {
!rest.contains('/') && self.reserved.contains(&crabidy_core::decode_segment(rest))
})
}
/// Deletes one track file and, when its playable is a local audio
/// file that resolves inside the instance root, that audio file too
/// (captures store audio next to the metadata; audio elsewhere on
/// disk — an `/fs`-style absolute reference — is never touched).
/// Idempotent: an already-gone track file is a success; a track file
/// that no longer parses still gets removed (its audio cannot be
/// located then, which the log notes).
async fn delete_track_file(&self, path: &str) -> Result<(), ProviderError> {
let disk = self.disk_path(path)?;
let audio = match self.read_track_file(&disk).await {
Ok(file) => match file.playable() {
Ok(Playable::File(target)) => {
let absolute = if target.is_absolute() {
target
} else {
disk.parent().map(|dir| dir.join(&target)).unwrap_or(target)
};
Some(absolute)
}
_ => None,
},
// read_track_file warned already; a missing file stays
// idempotent below, an unparseable one is deleted blind.
Err(_) => None,
};
match tokio::fs::remove_file(&disk).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(());
}
Err(err) => {
warn!(path, "cannot delete track file: {err}");
return Err(ProviderError::InternalError);
}
}
let Some(audio) = audio else {
return Ok(());
};
// Containment check on the *resolved* audio path: canonicalize
// both sides so `..` segments or symlinks in the track file
// cannot direct the delete outside the instance root.
let Ok(root) = tokio::fs::canonicalize(&self.root).await else {
return Ok(());
};
match tokio::fs::canonicalize(&audio).await {
Ok(canonical) if canonical.starts_with(&root) => {
if let Err(err) = tokio::fs::remove_file(&canonical).await {
if err.kind() != std::io::ErrorKind::NotFound {
warn!(path, "track file deleted, but not its audio: {err}");
}
}
}
Ok(_) => {
warn!(path, "keeping audio outside the instance root");
}
// Already gone (or unreadable): nothing left to delete.
Err(_) => {}
}
Ok(())
}
/// Reads and parses one serialized track file. Failures are warned /// Reads and parses one serialized track file. Failures are warned
/// here (with the file's path, never its contents) so every caller /// here (with the file's path, never its contents) so every caller
/// can simply skip. /// can simply skip.
@ -725,6 +818,7 @@ impl ProviderClient for Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: self.downloadable_nodes, is_downloadable: self.downloadable_nodes,
tracks_deletable: self.deletable_tree,
} }
} }
@ -789,24 +883,45 @@ impl ProviderClient for Client {
self.get_lib_node(&new_path).await self.get_lib_node(&new_path).await
} }
/// Deletes a modifiable top-level folder (editable instances only); /// Deletes a modifiable top-level folder (editable instances), or —
/// everything else is [`ProviderError::NotSupported`]. Idempotent: an /// on a deletable tree ([`Self::with_deletable_tree`]) — any nested
/// already-gone folder is a success. Returns the refreshed root /// folder recursively or a single track file together with its local
/// listing. /// audio. Everything else is [`ProviderError::NotSupported`].
/// Idempotent: an already-gone target is a success. Returns the
/// refreshed parent listing.
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> { async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let (disk, _name) = self.editable_child_dir(path)?; if self.deletable_tree && self.is_track_path(path) {
match tokio::fs::remove_dir_all(&disk).await { self.delete_track_file(path).await?;
Ok(()) => {} } else {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { let disk = match self.editable_child_dir(path) {
// Idempotent by contract (crabidy.proto). Ok((disk, _name)) => disk,
} // Below the top level only a deletable tree may delete —
Err(err) => { // never the instance root itself, and reserved names keep
warn!(path, "cannot delete folder: {err}"); // their immutability even there.
return Err(ProviderError::InternalError); Err(ProviderError::NotSupported)
if self.deletable_tree
&& path != self.provider_root
&& !self.is_reserved_top_level(path) =>
{
self.disk_path(path)?
}
Err(err) => return Err(err),
};
match tokio::fs::remove_dir_all(&disk).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// Idempotent by contract (crabidy.proto).
}
Err(err) => {
warn!(path, "cannot delete folder: {err}");
return Err(ProviderError::InternalError);
}
} }
} }
let root = self.provider_root.clone(); let parent = crabidy_core::parent_path(path)
self.get_lib_node(&root).await .map(String::from)
.unwrap_or_else(|| self.provider_root.clone());
self.get_lib_node(&parent).await
} }
// `resolve_tracks_into` deliberately keeps the default pre-order walk: // `resolve_tracks_into` deliberately keeps the default pre-order walk:
@ -1509,6 +1624,133 @@ mod tests {
.expect_err(undeletable); .expect_err(undeletable);
assert_eq!(err, ProviderError::NotSupported, "{undeletable}"); assert_eq!(err, ProviderError::NotSupported, "{undeletable}");
} }
// Neither are track files: only deletable trees delete tracks.
fs::create_dir(dir.path().join("hiking")).expect("mkdir");
write_track(
&dir.path().join("hiking"),
"0001 a.cbd-track.toml",
&url_track("a"),
);
let err = client
.delete_lib_node("/queues/hiking/0001%20a.cbd-track.toml")
.await
.expect_err("track");
assert_eq!(err, ProviderError::NotSupported);
}
/// A captures-shaped instance: editable top level plus a deletable
/// tree, with one nested album holding a downloaded track (audio next
/// to its toml) and a URL track.
async fn deletable_tree_client() -> (Client, TempDir) {
let dir = TempDir::new().expect("tempdir");
let album = dir.path().join("mix/album");
fs::create_dir_all(&album).expect("mkdir");
write_track(
&album,
"0001 song.cbd-track.toml",
"title = \"song\"\n[playable]\nfile = \"0001 song.m4a\"\n",
);
fs::write(album.join("0001 song.m4a"), b"audio").expect("write audio");
write_track(&album, "0002 radio.cbd-track.toml", &url_track("radio"));
let client = Client::new("/captures", dir.path().to_path_buf())
.expect("instance")
.with_editable_top_level(&[])
.with_deletable_tree();
(client, dir)
}
#[tokio::test]
async fn deletable_trees_flag_every_folder_and_track() {
let (client, _dir) = deletable_tree_client().await;
let root = client.get_lib_node("/captures").await.expect("root");
assert!(root.tracks_deletable);
let top = &root.children[0];
assert!(
top.is_editable && top.is_deletable,
"top level: rename + delete"
);
let nested = client
.get_lib_node("/captures/mix")
.await
.expect("nested listing");
assert!(nested.tracks_deletable);
let album = &nested.children[0];
assert!(album.is_deletable, "nested folders are deletable");
assert!(!album.is_editable, "but not renamable");
// Editable-only instances (queues, bookmarks) keep tracks and
// nested folders immutable.
let (queues, _dir2) = editable_client().await;
let root = queues.get_lib_node("/queues").await.expect("root");
assert!(!root.tracks_deletable);
}
#[tokio::test]
async fn deletable_trees_delete_nested_folders_recursively() {
let (client, dir) = deletable_tree_client().await;
let parent = client
.delete_lib_node("/captures/mix/album")
.await
.expect("delete");
assert_eq!(parent.path, "/captures/mix", "refreshed parent listing");
assert!(parent.children.is_empty());
assert!(!dir.path().join("mix/album").exists(), "gone from disk");
// Idempotent, like top-level deletes.
client
.delete_lib_node("/captures/mix/album")
.await
.expect("idempotent delete");
// The instance root itself stays undeletable.
let err = client.delete_lib_node("/captures").await.expect_err("root");
assert_eq!(err, ProviderError::NotSupported);
}
#[tokio::test]
async fn deleting_a_track_removes_its_file_and_local_audio() {
let (client, dir) = deletable_tree_client().await;
let album = dir.path().join("mix/album");
let parent = client
.delete_lib_node("/captures/mix/album/0001%20song.cbd-track.toml")
.await
.expect("delete track");
assert_eq!(parent.path, "/captures/mix/album");
assert_eq!(parent.tracks.len(), 1, "only the radio track remains");
assert!(!album.join("0001 song.cbd-track.toml").exists());
assert!(!album.join("0001 song.m4a").exists(), "audio deleted too");
// Idempotent: the track is already gone.
client
.delete_lib_node("/captures/mix/album/0001%20song.cbd-track.toml")
.await
.expect("idempotent delete");
// URL tracks have no local audio; only the toml goes.
client
.delete_lib_node("/captures/mix/album/0002%20radio.cbd-track.toml")
.await
.expect("delete url track");
assert!(!album.join("0002 radio.cbd-track.toml").exists());
}
#[tokio::test]
async fn deleting_a_track_never_touches_audio_outside_the_root() {
let (client, dir) = deletable_tree_client().await;
let outside = TempDir::new().expect("outside dir");
let audio = outside.path().join("keep.flac");
fs::write(&audio, b"audio").expect("write audio");
let album = dir.path().join("mix/album");
write_track(
&album,
"0003 external.cbd-track.toml",
&format!(
"title = \"external\"\n[playable]\nfile = {:?}\n",
audio.to_str().unwrap()
),
);
client
.delete_lib_node("/captures/mix/album/0003%20external.cbd-track.toml")
.await
.expect("delete track");
assert!(!album.join("0003 external.cbd-track.toml").exists());
assert!(audio.exists(), "external audio is not ours to delete");
} }
// ---- shared naming ---------------------------------------------------- // ---- shared naming ----------------------------------------------------

View File

@ -572,3 +572,35 @@ Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
- **`main.rs`** passes `tx` to `App::new` without the now-unneeded clone; the - **`main.rs`** passes `tx` to `App::new` without the now-unneeded clone; the
`KeyCode`/`KeyModifiers`/`UiFocus`/`StatefulList` imports moved out with the `KeyCode`/`KeyModifiers`/`UiFocus`/`StatefulList` imports moved out with the
old match. old match.
## capture-deletion (2026-07-21)
Deletes under `/captures` now work at any depth and remove data from
disk, behind a TUI confirmation (`architecture/capture-deletion.md`;
direct implementation, no separate plan file — the change is four
bounded seams):
- **Proto**: `LibraryNode.tracks_deletable` (field 9) — node-level
"listed tracks may be deleted", mirroring the `is_downloadable`
inheritance so no `Track` literal anywhere had to change.
`DeleteLibraryNode` doc extended to tracks and recursive folders.
- **fsdy**: `with_deletable_tree()` (only the `/captures` instance sets
it): nested folders delete recursively; track deletes remove the toml
plus its `[playable] file` audio **iff** the canonicalized audio path
stays inside the canonicalized instance root (`..`/symlink-proof);
reserved names and the instance root remain undeletable; everything
idempotent. Deletes now return the actual parent listing (was: root —
identical for the previously-only-possible top-level case).
- **crabidy-server**: captures fsdy instance gains the flag; the
delete RPC path was already generic.
- **cbd-tui**: tracks inherit `is_deletable` from `tracks_deletable`;
`d` under `/captures` opens a modal red `delete <title>? [y/N]` line
(only `y`/`Y` sends, any other key cancels) — other deletables stay
unconfirmed by design; `selected_deletable()` now returns
`(path, title)`.
Tests: 4 new fsdy tests (flags, recursive delete, track+audio delete,
outside-root audio kept) and 3 new TUI tests (confirm-then-send,
cancel-on-anything-else, prompt render) plus a guard in the existing
queues delete test that track deletion stays `NotSupported` there.
188 workspace tests green; clippy `-D warnings` and fmt clean.

View File

@ -129,6 +129,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
@ -157,6 +158,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}; };
let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?; let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?;
for playlist in self for playlist in self
@ -189,6 +191,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
TidalPath::Artists => { TidalPath::Artists => {
@ -201,6 +204,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}; };
let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?; let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?;
for artist in self.get_users_artists(&user_id).await? { for artist in self.get_users_artists(&user_id).await? {
@ -236,6 +240,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
TidalPath::Album { album, .. } => { TidalPath::Album { album, .. } => {
@ -255,6 +260,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
TidalPath::Search => crabidy_core::proto::crabidy::LibraryNode { TidalPath::Search => crabidy_core::proto::crabidy::LibraryNode {
@ -282,6 +288,7 @@ impl crabidy_core::ProviderClient for Client {
is_queable: false, is_queable: false,
is_creatable: true, is_creatable: true,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}, },
TidalPath::SearchTerm(encoded) => { TidalPath::SearchTerm(encoded) => {
let term = crabidy_core::decode_segment(encoded); let term = crabidy_core::decode_segment(encoded);
@ -915,6 +922,7 @@ impl Client {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}) })
} }

View File

@ -191,6 +191,7 @@ impl Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}) })
} }
@ -224,6 +225,7 @@ impl Client {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}) })
} }
@ -252,6 +254,7 @@ impl Client {
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}) })
} }
@ -428,6 +431,7 @@ impl ProviderClient for Client {
is_queable: false, is_queable: false,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
} }
} }
@ -462,6 +466,7 @@ impl ProviderClient for Client {
is_queable: false, is_queable: false,
is_creatable: true, is_creatable: true,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false,
}, },
YtPath::SearchTerm(encoded) => { YtPath::SearchTerm(encoded) => {
let term = crabidy_core::decode_segment(encoded); let term = crabidy_core::decode_segment(encoded);