Capture library subtrees as bookmarks
w on a queueable library selection snapshots the whole subtree into a third fsdy instance at /bookmarks: the orchestrator walks the source iteratively and mirrors it as order-prefixed folders of link track files (shared naming with queue persistence), tmp-and-swapped with size caps so a runaway tree cannot fill the disk. One additive rpc, CaptureLibraryNode(path, name), carries the flow; the TUI reuses the input overlay prefilled with the selection title. fsdy instances can now opt into an editable top level: root child folders carry is_editable/is_deletable and support no-merge rename and idempotent delete. /bookmarks mounts with it, and /queues too (reserving current), so saved queues are renamable and deletable through the existing e/d flows without TUI changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e44f672e8
commit
655e8054b8
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Bookmarks: capturing library subtrees
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
Queue persistence (architecture/queue-persistence.md) flattens the queue
|
||||||
|
into one folder of link files. The user now wants to **capture whole
|
||||||
|
subtrees**: pressing `w` on a queueable item in the *library* (an artist,
|
||||||
|
an album, a playlist folder) snapshots it into a local tree that
|
||||||
|
**preserves structure** — an artist becomes a folder of album folders,
|
||||||
|
each holding the playable track files. The store is one more local fs
|
||||||
|
provider ("bookmarks"); replaying is plain fs-provider behavior. On top,
|
||||||
|
the created top-level folders must be **renamable and deletable** from
|
||||||
|
the TUI — and the same should hold for saved queues.
|
||||||
|
|
||||||
|
## Assumptions (confirmed against the code)
|
||||||
|
|
||||||
|
- `fsdy::Client` is instance-mountable since queue persistence
|
||||||
|
(`Client::new(provider_root, disk_root)`); a third instance is cheap.
|
||||||
|
- The TUI already routes `e`/`d` through `is_editable`/`is_deletable`
|
||||||
|
flags on `LibraryNodeChild` into the existing
|
||||||
|
`RenameLibraryNode`/`DeleteLibraryNode` rpcs, and the orchestrator
|
||||||
|
already routes those to the `/queues` (and future `/bookmarks`)
|
||||||
|
instances. Making folders modifiable therefore needs **zero TUI
|
||||||
|
changes** — only fsdy must set flags and implement rename/delete.
|
||||||
|
- The capture walk can reuse `get_lib_node` through the
|
||||||
|
`ProviderOrchestrator` (any provider reachable), and
|
||||||
|
`TrackFile::from_track` + `track_file_name` from queue persistence for
|
||||||
|
the leaves.
|
||||||
|
- `w` is unbound in the TUI's `Library` scope; the input overlay handles
|
||||||
|
ask-for-a-name flows and supports prefilling (rename does).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Third fsdy instance `/bookmarks`; capture is a server-side walk
|
||||||
|
|
||||||
|
A `BookmarkStore` (sibling of `QueueStore`) owns
|
||||||
|
`<config>/crabidy/bookmarks/`; the orchestrator mounts a read-only fsdy
|
||||||
|
instance over it at `/bookmarks` and is the only writer. Capture runs on
|
||||||
|
the **orchestrator side** (it must call `get_lib_node` across providers):
|
||||||
|
a new `ProviderCommand::CaptureLibraryNode { path, name }` is handled on
|
||||||
|
a spawned task (like `ResolveTracks` — a big artist walk must not block
|
||||||
|
the loop). The reply arrives when the write finished.
|
||||||
|
|
||||||
|
Not chosen: capturing client-side in the TUI (would duplicate provider
|
||||||
|
access) or reusing the playback loop (captures are not queue state).
|
||||||
|
|
||||||
|
### D2 — Structure fidelity: order-prefixed folders and files
|
||||||
|
|
||||||
|
The walk mirrors the subtree iteratively (worklist, pre-order):
|
||||||
|
|
||||||
|
- Each child **node** becomes a folder named `NNNN <title>` (same
|
||||||
|
zero-padded prefix and sanitizer as queue entries, no suffix) — the
|
||||||
|
case-insensitive listing sort then reproduces the provider's child
|
||||||
|
order, which is meaningful (album track order, discography order).
|
||||||
|
- Each **track** becomes `NNNN <title>.cbd-track.toml` via the existing
|
||||||
|
`TrackFile::from_track` (uniform link playable) — metadata is captured
|
||||||
|
at save time; drift is accepted like everywhere else.
|
||||||
|
- A node carrying both tracks and children (search terms) writes both.
|
||||||
|
- Capturing a *track* selection is allowed: a folder with one link file.
|
||||||
|
- Whole-bookmark writes are tmp-and-swap like queues; an existing
|
||||||
|
bookmark of the same name is overwritten.
|
||||||
|
|
||||||
|
**Safety caps**: the walk aborts (typed error, temp dir removed) beyond
|
||||||
|
1 000 directories or 20 000 tracks — a runaway provider tree must not
|
||||||
|
fill the disk. Cycles are impossible under the cap (it bounds total
|
||||||
|
nodes, not depth). Since captured listings rewrite link tracks to their
|
||||||
|
targets, capturing a bookmark re-links to the *original* targets — no
|
||||||
|
link chains ever get written.
|
||||||
|
|
||||||
|
### D3 — New rpc `CaptureLibraryNode(path, name)`
|
||||||
|
|
||||||
|
Additive proto change (the only wire change). Name validation is shared
|
||||||
|
with queue saving (trimmed, no separators/NUL, no leading dot; no
|
||||||
|
reserved names in `/bookmarks`). Mapping: invalid name/source →
|
||||||
|
`invalid_argument`, capture disabled (no config dir) →
|
||||||
|
`failed_precondition`, walk/write failures → `internal`. The response is
|
||||||
|
empty — the TUI stays where it is (unlike `%`-create, capturing is not a
|
||||||
|
navigation; the bookmark appears under `/bookmarks` on the next visit).
|
||||||
|
|
||||||
|
### D4 — Mutable top-level folders as an fsdy instance option
|
||||||
|
|
||||||
|
`fsdy::Client` gains a builder option
|
||||||
|
`with_editable_top_level(reserved_names)`:
|
||||||
|
|
||||||
|
- The instance-root listing marks child *folders* `is_editable` and
|
||||||
|
`is_deletable`, except reserved names.
|
||||||
|
- `rename_lib_node`: only direct children of the instance root; new
|
||||||
|
title validated like a store name; renaming onto an existing sibling
|
||||||
|
is `InvalidInput` (folders never merge); returns the renamed node (the
|
||||||
|
TUI navigates into it, as with search terms).
|
||||||
|
- `delete_lib_node`: only direct children of the root; `remove_dir_all`;
|
||||||
|
idempotent (already gone → success); returns the refreshed root
|
||||||
|
listing.
|
||||||
|
- Deeper levels stay immutable — the user request covers the *created*
|
||||||
|
folders; restructuring inside a capture is file-manager work.
|
||||||
|
|
||||||
|
Applied to `/bookmarks` (no reserved names) **and `/queues`** (reserved:
|
||||||
|
`current`, which auto-persist owns — it can be neither renamed nor
|
||||||
|
deleted, and nothing can be renamed onto it). `/fs` keeps the immutable
|
||||||
|
default. Not chosen: implementing rename/delete in the stores — the
|
||||||
|
providers already own path→disk mapping and the rpc routing exists.
|
||||||
|
|
||||||
|
### D5 — TUI: `w` in the library scope
|
||||||
|
|
||||||
|
`Action::LibraryCaptureNode` bound to `w` in `Scope::Library` ("Save
|
||||||
|
selection as bookmark"): opens the input overlay **prefilled with the
|
||||||
|
selected item's title**, gated on the bare selection being queueable
|
||||||
|
(marks are ignored — one capture per invocation). Submit sends
|
||||||
|
`MessageFromUi::CaptureNode { path, name }` → the new rpc. Rename (`e`)
|
||||||
|
and delete (`d`) of bookmark/queue folders ride the existing flows via
|
||||||
|
the D4 flags.
|
||||||
|
|
||||||
|
### D6 — Out of scope (explicitly)
|
||||||
|
|
||||||
|
- Capturing multiple marked items at once; capture progress display.
|
||||||
|
- Rename/delete below the top level; moving bookmarks between folders.
|
||||||
|
- Refreshing a bookmark from its source (re-capture under the same name
|
||||||
|
overwrites — that *is* the refresh).
|
||||||
|
- A creatable `/bookmarks` root (`%`) — bookmarks are created from the
|
||||||
|
source tree.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
orch: ProviderOrchestrator {
|
||||||
|
cap: "capture walk (spawned):\nget_lib_node -> mirror tree"
|
||||||
|
}
|
||||||
|
bstore: BookmarkStore {
|
||||||
|
w: "validate name, caps,\ntmp-and-swap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tidal: tidaldy
|
||||||
|
fs: "fsdy /fs"
|
||||||
|
qfs: "fsdy /queues\n(editable top level,\nreserved: current)"
|
||||||
|
bfs: "fsdy /bookmarks\n(editable top level)"
|
||||||
|
|
||||||
|
disk: "config/crabidy/bookmarks" {
|
||||||
|
shape: cylinder
|
||||||
|
tree: "<name>/NNNN <album>/NNNN <track>.cbd-track.toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
server.orch.cap -> tidal: "walk source subtree"
|
||||||
|
server.orch.cap -> server.bstore: "write mirrored tree"
|
||||||
|
server.bstore -> disk
|
||||||
|
bfs -> disk: "list + parse (read only)"
|
||||||
|
server.orch -> bfs: "/bookmarks/... (browse, queue, e/d)"
|
||||||
|
server.orch -> qfs: "e/d on saved queues"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key flow: capture an artist, rename it, replay an album
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
tui: TUI
|
||||||
|
orch: Orchestrator
|
||||||
|
tidal: tidaldy
|
||||||
|
store: BookmarkStore
|
||||||
|
|
||||||
|
tui -> orch: "CaptureLibraryNode(/tidal/artists/42, faves)"
|
||||||
|
orch -> tidal: "get_lib_node (artist, albums, ...)"
|
||||||
|
orch -> store: "write faves/0001 Album/0001 Song.cbd-track.toml ..."
|
||||||
|
store -> tui: OK
|
||||||
|
tui -> orch: "RenameLibraryNode(/bookmarks/faves, road faves)"
|
||||||
|
orch -> tui: "renamed node (TUI navigates in)"
|
||||||
|
tui -> orch: "ReplaceQueue([/bookmarks/road%20faves/0001%20Album])"
|
||||||
|
orch -> tui: "resolve walk streams the album's tracks"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **Capture duration**: a large artist means many provider fetches; the
|
||||||
|
TUI's poll loop awaits the rpc like other slow calls (accepted,
|
||||||
|
consistent with search-term creation). The orchestrator loop itself
|
||||||
|
stays free (spawned task).
|
||||||
|
- **Rename/delete racing a re-capture** of the same name: last writer
|
||||||
|
wins on the swap; accepted for a single-user local server.
|
||||||
|
- **Prefix width** (9999 entries per folder) shared with queues;
|
||||||
|
accepted.
|
||||||
|
- Open (future): re-capture/refresh command; capturing marked sets;
|
||||||
|
editable nesting.
|
||||||
|
|
@ -142,9 +142,11 @@ library visit — no push update needed.
|
||||||
|
|
||||||
### D8 — Out of scope (explicitly)
|
### D8 — Out of scope (explicitly)
|
||||||
|
|
||||||
- Renaming/deleting saved queues from the TUI (`fsdy` keeps
|
- ~~Renaming/deleting saved queues from the TUI~~ — delivered by the
|
||||||
create/rename/delete `NotSupported`); file tools work today, marking
|
bookmarks feature (architecture/bookmarks.md D4): `/queues` mounts with
|
||||||
`/queues` children deletable is future work.
|
an editable top level (reserved: `current`), so saved queues are
|
||||||
|
renamable (`e`) and deletable (`d`). Creating nodes stays
|
||||||
|
`NotSupported`.
|
||||||
- Making the queues directory configurable; it is derived from the
|
- Making the queues directory configurable; it is derived from the
|
||||||
config dir.
|
config dir.
|
||||||
- Persisting the playback *position within the track*, autoplay on
|
- Persisting the playback *position within the track*, autoplay on
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ pub enum Action {
|
||||||
/// Deliberately unconfirmed: today's deletable nodes (search terms) are
|
/// Deliberately unconfirmed: today's deletable nodes (search terms) are
|
||||||
/// free to recreate (architecture/node-editing.md, D4).
|
/// free to recreate (architecture/node-editing.md, D4).
|
||||||
LibraryDeleteNode,
|
LibraryDeleteNode,
|
||||||
|
/// Open the input overlay (prefilled with the selection's title) to
|
||||||
|
/// capture the selected queueable subtree as a bookmark under
|
||||||
|
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
|
||||||
|
LibraryCaptureNode,
|
||||||
// Queue pane
|
// Queue pane
|
||||||
QueueInsertHere,
|
QueueInsertHere,
|
||||||
QueueFirst,
|
QueueFirst,
|
||||||
|
|
@ -255,6 +259,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::LibraryToggleMark,
|
action: Action::LibraryToggleMark,
|
||||||
description: "Mark/unmark selection",
|
description: "Mark/unmark selection",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('w'),
|
||||||
|
action: Action::LibraryCaptureNode,
|
||||||
|
description: "Save selection as bookmark",
|
||||||
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
|
|
@ -535,7 +546,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_binds_in_the_queue_scope_only() {
|
fn w_saves_the_queue_or_captures_the_selection_per_pane() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup(
|
lookup(
|
||||||
UiFocus::Queue,
|
UiFocus::Queue,
|
||||||
|
|
@ -550,7 +561,7 @@ mod tests {
|
||||||
false,
|
false,
|
||||||
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||||
),
|
),
|
||||||
None
|
Some(Action::LibraryCaptureNode)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,14 @@ impl Library {
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
/// Path and title of the bare selection, if it is queueable — what `w`
|
||||||
|
/// captures as a bookmark. Marks are deliberately ignored: one capture
|
||||||
|
/// per invocation (architecture/bookmarks.md D5).
|
||||||
|
pub fn selected_queueable(&self) -> Option<(String, String)> {
|
||||||
|
let item = self.list.get(self.list_state.selected()?)?;
|
||||||
|
item.is_queable
|
||||||
|
.then(|| (item.path.clone(), item.title.clone()))
|
||||||
|
}
|
||||||
pub fn get_selected(&self) -> Option<Vec<String>> {
|
pub fn get_selected(&self) -> Option<Vec<String>> {
|
||||||
if self.list.iter().any(|i| i.marked) {
|
if self.list.iter().any(|i| i.marked) {
|
||||||
return Some(
|
return Some(
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,12 @@ pub enum MessageFromUi {
|
||||||
/// Save the current queue under a name (server-side snapshot; appears
|
/// Save the current queue under a name (server-side snapshot; appears
|
||||||
/// as `/queues/<name>` in the library on the next visit).
|
/// as `/queues/<name>` in the library on the next visit).
|
||||||
SaveQueue(String),
|
SaveQueue(String),
|
||||||
|
/// Capture the queueable subtree at `path` as the bookmark `name`
|
||||||
|
/// (structure-preserving snapshot under `/bookmarks/<name>`).
|
||||||
|
CaptureNode {
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
AppendTracks(Vec<String>),
|
AppendTracks(Vec<String>),
|
||||||
QueueTracks(Vec<String>),
|
QueueTracks(Vec<String>),
|
||||||
InsertTracks(Vec<String>, usize),
|
InsertTracks(Vec<String>, usize),
|
||||||
|
|
@ -128,6 +134,9 @@ pub enum InputPurpose {
|
||||||
Rename { path: String },
|
Rename { path: String },
|
||||||
/// `w`: save the current queue under the entered name.
|
/// `w`: save the current queue under the entered name.
|
||||||
SaveQueue,
|
SaveQueue,
|
||||||
|
/// `w` in the library: capture the queueable subtree at `path` as a
|
||||||
|
/// bookmark. The buffer starts prefilled with the selection's title.
|
||||||
|
Capture { path: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of the one-line text input overlay (node creation and rename).
|
/// State of the one-line text input overlay (node creation and rename).
|
||||||
|
|
@ -204,6 +213,12 @@ impl App {
|
||||||
InputPurpose::SaveQueue => {
|
InputPurpose::SaveQueue => {
|
||||||
let _ = self.tx.send(MessageFromUi::SaveQueue(title));
|
let _ = self.tx.send(MessageFromUi::SaveQueue(title));
|
||||||
}
|
}
|
||||||
|
InputPurpose::Capture { path } => {
|
||||||
|
let _ = self.tx.send(MessageFromUi::CaptureNode {
|
||||||
|
path: path.clone(),
|
||||||
|
name: title,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.input = None;
|
self.input = None;
|
||||||
|
|
@ -292,6 +307,16 @@ impl App {
|
||||||
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::LibraryCaptureNode => {
|
||||||
|
// Prefilled with the selection's title; silently ignored on
|
||||||
|
// non-queueable selections like the other gated openers.
|
||||||
|
if let Some((path, title)) = self.library.selected_queueable() {
|
||||||
|
self.input = Some(InputState {
|
||||||
|
purpose: InputPurpose::Capture { path },
|
||||||
|
buffer: title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::QueueInsertHere => {
|
Action::QueueInsertHere => {
|
||||||
if let Some(selected) = self.queue.selected() {
|
if let Some(selected) = self.queue.selected() {
|
||||||
self.library.queue_insert(selected);
|
self.library.queue_insert(selected);
|
||||||
|
|
@ -364,6 +389,7 @@ impl App {
|
||||||
InputPurpose::Create { .. } => "new node",
|
InputPurpose::Create { .. } => "new node",
|
||||||
InputPurpose::Rename { .. } => "rename",
|
InputPurpose::Rename { .. } => "rename",
|
||||||
InputPurpose::SaveQueue => "save queue",
|
InputPurpose::SaveQueue => "save queue",
|
||||||
|
InputPurpose::Capture { .. } => "bookmark",
|
||||||
};
|
};
|
||||||
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
||||||
f.render_widget(Clear, line);
|
f.render_widget(Clear, line);
|
||||||
|
|
@ -736,6 +762,66 @@ mod tests {
|
||||||
assert!(text.contains("rename: abba"), "rename overlay label");
|
assert!(text.contains("rename: abba"), "rename overlay label");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An artists listing with one queueable child.
|
||||||
|
fn queueable_listing() -> LibraryNode {
|
||||||
|
use crabidy_core::proto::crabidy::LibraryNodeChild;
|
||||||
|
LibraryNode {
|
||||||
|
children: vec![LibraryNodeChild::new(
|
||||||
|
"/tidal/artists/1".to_string(),
|
||||||
|
"artist".to_string(),
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
..creatable_node("/tidal/artists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_opens_prefilled_only_on_queueable_selections() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
// Nothing loaded: 'w' must be a no-op.
|
||||||
|
assert_eq!(
|
||||||
|
app.dispatch(Action::LibraryCaptureNode),
|
||||||
|
DispatchResult::Continue
|
||||||
|
);
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
|
||||||
|
// A non-queueable selection must not open the overlay.
|
||||||
|
app.library.update(search_listing(false));
|
||||||
|
let _ = app.dispatch(Action::LibraryCaptureNode);
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
|
||||||
|
app.library.update(queueable_listing());
|
||||||
|
let _ = app.dispatch(Action::LibraryCaptureNode);
|
||||||
|
let input = app.input.as_ref().expect("capture overlay open");
|
||||||
|
assert!(
|
||||||
|
matches!(&input.purpose, InputPurpose::Capture { path } if path == "/tidal/artists/1")
|
||||||
|
);
|
||||||
|
assert_eq!(input.buffer, "artist", "prefilled with the title");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_submits_the_trimmed_name_and_esc_cancels() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.library.update(queueable_listing());
|
||||||
|
|
||||||
|
let _ = app.dispatch(Action::LibraryCaptureNode);
|
||||||
|
type_str(&mut app, " favs ");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Enter));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(MessageFromUi::CaptureNode { path, name }) => {
|
||||||
|
assert_eq!(path, "/tidal/artists/1");
|
||||||
|
assert_eq!(name, "artist favs");
|
||||||
|
}
|
||||||
|
other => panic!("expected CaptureNode, got {:?}", other.is_ok()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.dispatch(Action::LibraryCaptureNode);
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Esc));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
assert!(rx.try_recv().is_err(), "Esc must not capture");
|
||||||
|
}
|
||||||
|
|
||||||
/// A one-track queue update, as the server would broadcast it.
|
/// A one-track queue update, as the server would broadcast it.
|
||||||
fn one_track_queue() -> crabidy_core::proto::crabidy::Queue {
|
fn one_track_queue() -> crabidy_core::proto::crabidy::Queue {
|
||||||
use crabidy_core::proto::crabidy::{Queue as ProtoQueue, Track};
|
use crabidy_core::proto::crabidy::{Queue as ProtoQueue, Track};
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,13 @@ async fn poll(
|
||||||
error!(name, "failed to save queue: {err}");
|
error!(name, "failed to save queue: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MessageFromUi::CaptureNode { path, name } => {
|
||||||
|
// A rejected capture (bad name, over-cap subtree) must
|
||||||
|
// not tear down the poll loop either.
|
||||||
|
if let Err(err) = rpc_client.capture_library_node(path.clone(), name.clone()).await {
|
||||||
|
error!(path, name, "failed to capture subtree: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(resp) = rpc_client.update_stream.next() => {
|
Some(resp) = rpc_client.update_stream.next() => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||||
ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest,
|
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||||
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||||
LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, RenameLibraryNodeRequest,
|
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
||||||
ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SetCurrentRequest, ToggleMuteRequest,
|
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||||
|
ToggleShuffleRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||||
|
|
@ -220,6 +221,16 @@ impl RpcClient {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn capture_library_node(
|
||||||
|
&mut self,
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let capture_request = Request::new(CaptureLibraryNodeRequest { path, name });
|
||||||
|
self.client.capture_library_node(capture_request).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let replace_request = Request::new(ReplaceRequest { paths });
|
let replace_request = Request::new(ReplaceRequest { paths });
|
||||||
self.client.replace(replace_request).await?;
|
self.client.replace(replace_request).await?;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ service CrabidyService {
|
||||||
// Deletes a node whose listing entry sets is_deletable. Idempotent:
|
// Deletes a node whose listing entry sets is_deletable. 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
|
||||||
|
// structure-preserving snapshot under /bookmarks/<name> (folders per
|
||||||
|
// child node, link track files per track). Overwrites an existing
|
||||||
|
// bookmark of the same name. The client stays where it is — the bookmark
|
||||||
|
// shows up under /bookmarks on the next visit.
|
||||||
|
rpc CaptureLibraryNode(CaptureLibraryNodeRequest) returns (CaptureLibraryNodeResponse);
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
rpc Queue(QueueRequest) returns (QueueResponse);
|
rpc Queue(QueueRequest) returns (QueueResponse);
|
||||||
|
|
@ -96,6 +102,14 @@ message DeleteLibraryNodeResponse {
|
||||||
LibraryNode parent = 1;
|
LibraryNode parent = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CaptureLibraryNodeRequest {
|
||||||
|
// Path of the queueable node (or track) to capture.
|
||||||
|
string path = 1;
|
||||||
|
// Bookmark name; becomes the top-level folder under /bookmarks.
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
message CaptureLibraryNodeResponse {}
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
message QueueRequest {
|
message QueueRequest {
|
||||||
repeated string paths = 1;
|
repeated string paths = 1;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
//! Captured library subtrees ("bookmarks") on disk
|
||||||
|
//! (see `architecture/bookmarks.md`).
|
||||||
|
//!
|
||||||
|
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
|
||||||
|
//! mirrors the captured subtree: one order-prefixed folder per child node,
|
||||||
|
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
|
||||||
|
//! directory is mounted read-only into the library as `/bookmarks` by an
|
||||||
|
//! `fsdy` instance (with editable top-level folders) — this module is the
|
||||||
|
//! only writer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crabidy_core::ProviderClient;
|
||||||
|
|
||||||
|
/// The library mount point of the bookmarks directory.
|
||||||
|
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
|
||||||
|
|
||||||
|
/// The walk aborts beyond this many directories — a runaway provider tree
|
||||||
|
/// must not fill the disk.
|
||||||
|
pub const MAX_CAPTURE_DIRS: usize = 1_000;
|
||||||
|
|
||||||
|
/// The walk aborts beyond this many track files.
|
||||||
|
pub const MAX_CAPTURE_TRACKS: usize = 20_000;
|
||||||
|
|
||||||
|
/// The bookmarks directory: `bookmarks/` inside the crabidy config
|
||||||
|
/// directory. `None` when the platform has no config directory.
|
||||||
|
pub fn bookmarks_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from validating or writing a capture.
|
||||||
|
///
|
||||||
|
/// At the RPC boundary: `InvalidName`/`BadSource` → `invalid_argument`,
|
||||||
|
/// `TooLarge` → `failed_precondition`, the rest → `internal`. Messages
|
||||||
|
/// carry names and paths, never file contents.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum CaptureError {
|
||||||
|
#[error("invalid bookmark name: {0}")]
|
||||||
|
InvalidName(&'static str),
|
||||||
|
#[error("bookmarks are disabled")]
|
||||||
|
Disabled,
|
||||||
|
#[error("the source path cannot be captured: {0}")]
|
||||||
|
BadSource(String),
|
||||||
|
#[error("the subtree is too large to capture ({0})")]
|
||||||
|
TooLarge(&'static str),
|
||||||
|
#[error("cannot write bookmark: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
TrackFile(#[from] fsdy::TrackFileError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
|
||||||
|
/// built as a hidden temp sibling and swapped into place, so a crash never
|
||||||
|
/// leaves a half-written bookmark next to intact ones.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BookmarkStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BookmarkStore {
|
||||||
|
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||||
|
/// missing.
|
||||||
|
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
Ok(Self { dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store directory (what the `/bookmarks` provider instance
|
||||||
|
/// mounts).
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the subtree at `source_path` as the bookmark `name`,
|
||||||
|
/// overwriting an existing bookmark of that name.
|
||||||
|
///
|
||||||
|
/// Walks `client` (the orchestrator, so any provider is reachable)
|
||||||
|
/// iteratively in pre-order: every child node becomes an
|
||||||
|
/// order-prefixed folder, every track an order-prefixed link file
|
||||||
|
/// ([`fsdy::TrackFile::from_track`]). A `source_path` that is itself a
|
||||||
|
/// track captures as a folder with one file. Aborts with
|
||||||
|
/// [`CaptureError::TooLarge`] beyond [`MAX_CAPTURE_DIRS`] /
|
||||||
|
/// [`MAX_CAPTURE_TRACKS`]; an unreadable source is
|
||||||
|
/// [`CaptureError::BadSource`]. Never panics on provider contents.
|
||||||
|
pub async fn capture<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
self.capture_with_caps(
|
||||||
|
client,
|
||||||
|
source_path,
|
||||||
|
name,
|
||||||
|
MAX_CAPTURE_DIRS,
|
||||||
|
MAX_CAPTURE_TRACKS,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||||
|
async fn capture_with_caps<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
max_dirs: usize,
|
||||||
|
max_tracks: usize,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||||
|
let tmp = self.dir.join(format!(".tmp-{name}"));
|
||||||
|
let written = self
|
||||||
|
.write_capture(client, source_path, &tmp, max_dirs, max_tracks)
|
||||||
|
.await;
|
||||||
|
if let Err(err) = written {
|
||||||
|
// Every failure path removes the temp folder: nothing
|
||||||
|
// half-written survives, not even hidden.
|
||||||
|
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
let target = self.dir.join(name);
|
||||||
|
if tokio::fs::try_exists(&target).await? {
|
||||||
|
tokio::fs::remove_dir_all(&target).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&tmp, &target).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the mirrored tree inside `tmp`. All-or-nothing: any provider
|
||||||
|
/// or write failure aborts the whole capture (the caller removes
|
||||||
|
/// `tmp`) — a bookmark that *looks* complete must *be* complete.
|
||||||
|
async fn write_capture<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
tmp: &Path,
|
||||||
|
max_dirs: usize,
|
||||||
|
max_tracks: usize,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
// A leftover temp folder from a crashed or racing capture is stale.
|
||||||
|
if tokio::fs::try_exists(tmp).await? {
|
||||||
|
tokio::fs::remove_dir_all(tmp).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(tmp).await?;
|
||||||
|
|
||||||
|
// A track source captures as a folder with one file.
|
||||||
|
if client.is_track_path(source_path) {
|
||||||
|
let track = client
|
||||||
|
.get_metadata_for_track(source_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| CaptureError::BadSource(format!("{source_path}: {err}")))?;
|
||||||
|
let text = fsdy::TrackFile::from_track(&track).to_toml()?;
|
||||||
|
let file = tmp.join(fsdy::track_file_name(0, &track.title));
|
||||||
|
tokio::fs::write(file, text).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dirs = 1usize;
|
||||||
|
let mut tracks = 0usize;
|
||||||
|
// Iterative pre-order: a deep provider tree must not overflow the
|
||||||
|
// stack. Each entry pairs a library path with its mirror folder.
|
||||||
|
let mut worklist: Vec<(String, PathBuf)> =
|
||||||
|
vec![(source_path.to_string(), tmp.to_path_buf())];
|
||||||
|
while let Some((lib_path, dir)) = worklist.pop() {
|
||||||
|
let node = client
|
||||||
|
.get_lib_node(&lib_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| CaptureError::BadSource(format!("{lib_path}: {err}")))?;
|
||||||
|
for (index, track) in node.tracks.iter().enumerate() {
|
||||||
|
tracks += 1;
|
||||||
|
if tracks > max_tracks {
|
||||||
|
return Err(CaptureError::TooLarge("too many tracks"));
|
||||||
|
}
|
||||||
|
let text = fsdy::TrackFile::from_track(track).to_toml()?;
|
||||||
|
let file = dir.join(fsdy::track_file_name(index, &track.title));
|
||||||
|
tokio::fs::write(file, text).await?;
|
||||||
|
}
|
||||||
|
for (index, child) in node.children.iter().enumerate() {
|
||||||
|
dirs += 1;
|
||||||
|
if dirs > max_dirs {
|
||||||
|
return Err(CaptureError::TooLarge("too many directories"));
|
||||||
|
}
|
||||||
|
let child_dir = dir.join(fsdy::dir_name(index, &child.title));
|
||||||
|
tokio::fs::create_dir(&child_dir).await?;
|
||||||
|
worklist.push((child.path.clone(), child_dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A real fsdy instance as the capture source: an artist with two
|
||||||
|
/// albums holding url tracks, plus one link track pointing at Tidal.
|
||||||
|
async fn source() -> (fsdy::Client, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("source tempdir");
|
||||||
|
let al1 = dir.path().join("artist/Album One");
|
||||||
|
let al2 = dir.path().join("artist/Album Two");
|
||||||
|
fs::create_dir_all(&al1).expect("mkdir");
|
||||||
|
fs::create_dir_all(&al2).expect("mkdir");
|
||||||
|
let url =
|
||||||
|
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
|
||||||
|
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
|
||||||
|
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
|
||||||
|
fs::write(
|
||||||
|
al2.join("01 linked.cbd-track.toml"),
|
||||||
|
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
|
||||||
|
(client, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store() -> (BookmarkStore, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("store tempdir");
|
||||||
|
let store = BookmarkStore::open(dir.path().join("bookmarks"))
|
||||||
|
.await
|
||||||
|
.expect("open creates the directory");
|
||||||
|
(store, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = fs::read_dir(dir)
|
||||||
|
.expect("dir")
|
||||||
|
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| !n.starts_with('.'))
|
||||||
|
.collect();
|
||||||
|
names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_mirrors_a_subtree_with_order_prefixes() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist", "faves")
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
|
||||||
|
let root = store.dir().join("faves");
|
||||||
|
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
|
||||||
|
let album1 = visible(&root.join("0001 Album One"));
|
||||||
|
assert_eq!(
|
||||||
|
album1,
|
||||||
|
vec![
|
||||||
|
"0001 one.cbd-track.toml".to_string(),
|
||||||
|
"0002 two.cbd-track.toml".into()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Entries are link files; the url track links back to its /fs path,
|
||||||
|
// the link track re-links to its original target (no chains).
|
||||||
|
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
|
||||||
|
.expect("read entry");
|
||||||
|
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
|
||||||
|
assert_eq!(
|
||||||
|
one.to_track("/bookmarks/irrelevant").path,
|
||||||
|
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
|
||||||
|
);
|
||||||
|
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
|
||||||
|
.expect("read entry");
|
||||||
|
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
|
||||||
|
assert_eq!(
|
||||||
|
linked.to_track("/bookmarks/irrelevant").path,
|
||||||
|
"/tidal/artists/1/2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn captured_trees_replay_through_a_bookmarks_instance() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist", "faves")
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
|
||||||
|
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||||
|
.expect("bookmarks instance");
|
||||||
|
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||||
|
bookmarks
|
||||||
|
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
|
||||||
|
.await
|
||||||
|
.expect("resolve");
|
||||||
|
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
|
||||||
|
// Pre-order over the mirrored structure == source listing order.
|
||||||
|
assert_eq!(
|
||||||
|
titles,
|
||||||
|
vec!["one".to_string(), "two".into(), "linked".into()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capturing_a_single_track_writes_one_file() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(
|
||||||
|
&client,
|
||||||
|
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
|
||||||
|
"just one",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capture track");
|
||||||
|
let entries = visible(&store.dir().join("just one"));
|
||||||
|
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_validates_names_and_overwrites() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
for bad in ["", " ", "a/b", ".hidden"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.capture(&client, "/fs/artist", bad).await,
|
||||||
|
Err(CaptureError::InvalidName(_))
|
||||||
|
),
|
||||||
|
"name {bad:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist", "faves")
|
||||||
|
.await
|
||||||
|
.expect("first capture");
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist/Album%20Two", "faves")
|
||||||
|
.await
|
||||||
|
.expect("overwrite");
|
||||||
|
// The overwrite fully replaces the older, larger capture.
|
||||||
|
assert_eq!(
|
||||||
|
visible(&store.dir().join("faves")),
|
||||||
|
vec!["0001 linked.cbd-track.toml"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_rejects_an_unreadable_source() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
assert!(matches!(
|
||||||
|
store.capture(&client, "/fs/nope", "x").await,
|
||||||
|
Err(CaptureError::BadSource(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_aborts_over_the_caps_and_cleans_up() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
|
||||||
|
let err = store
|
||||||
|
.capture_with_caps(&client, "/fs/artist", "big", 2, MAX_CAPTURE_TRACKS)
|
||||||
|
.await
|
||||||
|
.expect_err("over the dir cap");
|
||||||
|
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||||
|
// ... same for the track cap.
|
||||||
|
let err = store
|
||||||
|
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1)
|
||||||
|
.await
|
||||||
|
.expect_err("over the track cap");
|
||||||
|
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||||
|
// Nothing half-written survives, not even hidden temp folders.
|
||||||
|
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
|
||||||
|
assert_eq!(leftovers, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod bookmark_store;
|
||||||
pub mod queue_store;
|
pub mod queue_store;
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{Queue, Track};
|
use crabidy_core::proto::crabidy::{Queue, Track};
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,14 @@ pub enum ProviderCommand {
|
||||||
path: String,
|
path: String,
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
},
|
},
|
||||||
|
/// Captures the queueable subtree at `path` as the bookmark `name`
|
||||||
|
/// (see `architecture/bookmarks.md` D1–D3). Handled on a spawned task —
|
||||||
|
/// a large walk must not block the orchestrator loop.
|
||||||
|
CaptureLibraryNode {
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
result_tx: flume::Sender<Result<(), crabidy_server::bookmark_store::CaptureError>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderCommand {
|
impl ProviderCommand {
|
||||||
|
|
@ -215,6 +223,7 @@ impl ProviderCommand {
|
||||||
Self::CreateLibraryNode { .. } => "create_library_node",
|
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||||
Self::RenameLibraryNode { .. } => "rename_library_node",
|
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||||||
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||||||
|
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ use crabidy_core::{
|
||||||
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
||||||
ProviderClient, ProviderError,
|
ProviderClient, ProviderError,
|
||||||
};
|
};
|
||||||
use crabidy_server::queue_store::QUEUES_PROVIDER_ROOT;
|
use crabidy_server::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
||||||
|
use crabidy_server::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
||||||
use std::{fs, path::PathBuf, sync::Arc};
|
use std::{fs, path::PathBuf, sync::Arc};
|
||||||
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
||||||
|
|
||||||
|
|
@ -20,6 +21,13 @@ pub struct ProviderOrchestrator {
|
||||||
/// `/queues` (architecture/queue-persistence.md D1). `None` without a
|
/// `/queues` (architecture/queue-persistence.md D1). `None` without a
|
||||||
/// config directory — the server then runs without `/queues`.
|
/// config directory — the server then runs without `/queues`.
|
||||||
queues_client: Option<Arc<fsdy::Client>>,
|
queues_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// Third `fsdy` instance over the bookmarks folder, mounted at
|
||||||
|
/// `/bookmarks` (architecture/bookmarks.md D1). `None` without a
|
||||||
|
/// config directory.
|
||||||
|
bookmarks_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// The capture writer; `None` disables `CaptureLibraryNode` (and the
|
||||||
|
/// `/bookmarks` mount goes with it).
|
||||||
|
bookmark_store: Option<Arc<BookmarkStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a path belongs to the filesystem provider.
|
/// Whether a path belongs to the filesystem provider.
|
||||||
|
|
@ -33,6 +41,11 @@ fn queues_owns(path: &str) -> bool {
|
||||||
path == QUEUES_PROVIDER_ROOT || path.starts_with("/queues/")
|
path == QUEUES_PROVIDER_ROOT || path.starts_with("/queues/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the bookmarks provider instance.
|
||||||
|
fn bookmarks_owns(path: &str) -> bool {
|
||||||
|
path == BOOKMARKS_PROVIDER_ROOT || path.starts_with("/bookmarks/")
|
||||||
|
}
|
||||||
|
|
||||||
impl ProviderOrchestrator {
|
impl ProviderOrchestrator {
|
||||||
/// The fs client, or `MalformedPath` (with a warning) when the
|
/// The fs client, or `MalformedPath` (with a warning) when the
|
||||||
/// provider is disabled — a `/fs` path then has no owner.
|
/// provider is disabled — a `/fs` path then has no owner.
|
||||||
|
|
@ -51,6 +64,15 @@ impl ProviderOrchestrator {
|
||||||
ProviderError::MalformedPath
|
ProviderError::MalformedPath
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bookmarks client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// instance is disabled — a `/bookmarks` path then has no owner.
|
||||||
|
fn bookmarks_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.bookmarks_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("bookmarks library is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Behind an Arc so long-running resolves can be spawned onto
|
// Behind an Arc so long-running resolves can be spawned onto
|
||||||
|
|
@ -123,6 +145,31 @@ impl ProviderOrchestrator {
|
||||||
error!("failed to send delete_library_node result: {err}");
|
error!("failed to send delete_library_node result: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProviderCommand::CaptureLibraryNode {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
result_tx,
|
||||||
|
} => {
|
||||||
|
// Spawned: capturing a large artist walks many provider
|
||||||
|
// nodes and must not block this loop (the walk itself
|
||||||
|
// calls back into `get_lib_node` via `this`).
|
||||||
|
let this = Arc::clone(&self);
|
||||||
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
|
let result = match &this.bookmark_store {
|
||||||
|
Some(store) => store.capture(&*this, &path, &name).await,
|
||||||
|
None => Err(crabidy_server::bookmark_store::CaptureError::Disabled),
|
||||||
|
};
|
||||||
|
if let Err(err) = &result {
|
||||||
|
warn!(path, name, "cannot capture subtree: {err}");
|
||||||
|
}
|
||||||
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
|
error!("failed to send capture_library_node result: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,10 +221,13 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
};
|
};
|
||||||
// The queues instance mounts the folder the playback side persists
|
// The queues instance mounts the folder the playback side persists
|
||||||
// into; a folder that does not exist yet lists as empty-on-arrival
|
// into; a folder that does not exist yet lists as empty-on-arrival
|
||||||
// (created by QueueStore::open in main).
|
// (created by QueueStore::open in main). Saved queues are renamable
|
||||||
|
// and deletable; the auto-persisted `current` stays untouchable.
|
||||||
let queues_client = match crabidy_server::queue_store::queues_dir() {
|
let queues_client = match crabidy_server::queue_store::queues_dir() {
|
||||||
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
||||||
Ok(client) => Some(Arc::new(client)),
|
Ok(client) => Some(Arc::new(
|
||||||
|
client.with_editable_top_level(&[CURRENT_QUEUE_NAME]),
|
||||||
|
)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("queues library disabled: {err}");
|
warn!("queues library disabled: {err}");
|
||||||
None
|
None
|
||||||
|
|
@ -188,6 +238,31 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Bookmarks: the orchestrator owns the store (it is the capture
|
||||||
|
// writer) and mounts the same folder read-only. Non-fatal like the
|
||||||
|
// other local providers.
|
||||||
|
let bookmark_store = match crabidy_server::bookmark_store::bookmarks_dir() {
|
||||||
|
Some(dir) => match BookmarkStore::open(dir).await {
|
||||||
|
Ok(store) => Some(Arc::new(store)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("bookmarks disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("bookmarks disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bookmarks_client = bookmark_store.as_ref().and_then(|store| {
|
||||||
|
match fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf()) {
|
||||||
|
Ok(client) => Some(Arc::new(client.with_editable_top_level(&[]))),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("bookmarks library disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
let (provider_tx, provider_rx) = flume::bounded(100);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
provider_rx,
|
provider_rx,
|
||||||
|
|
@ -195,6 +270,8 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
tidal_client,
|
tidal_client,
|
||||||
fs_client,
|
fs_client,
|
||||||
queues_client,
|
queues_client,
|
||||||
|
bookmarks_client,
|
||||||
|
bookmark_store,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,6 +296,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|queues| queues.is_track_path(path));
|
.is_some_and(|queues| queues.is_track_path(path));
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|bookmarks| bookmarks.is_track_path(path));
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,6 +316,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if queues_owns(track_path) {
|
if queues_owns(track_path) {
|
||||||
return self.queues_provider()?.get_urls_for_track(track_path).await;
|
return self.queues_provider()?.get_urls_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.get_urls_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -251,6 +340,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.get_metadata_for_track(track_path)
|
.get_metadata_for_track(track_path)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -270,6 +365,14 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
LibraryNodeChild::new(QUEUES_PROVIDER_ROOT.to_owned(), "queues".to_owned(), false);
|
LibraryNodeChild::new(QUEUES_PROVIDER_ROOT.to_owned(), "queues".to_owned(), false);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
}
|
}
|
||||||
|
if self.bookmarks_client.is_some() {
|
||||||
|
let child = LibraryNodeChild::new(
|
||||||
|
BOOKMARKS_PROVIDER_ROOT.to_owned(),
|
||||||
|
"bookmarks".to_owned(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
root_node
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,6 +391,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if queues_owns(path) {
|
if queues_owns(path) {
|
||||||
return self.queues_provider()?.get_lib_node(path).await;
|
return self.queues_provider()?.get_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self.bookmarks_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +421,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.create_lib_node(parent_path, title)
|
.create_lib_node(parent_path, title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(parent_path, "no provider supports creating nodes here");
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -339,6 +451,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.rename_lib_node(path, new_title)
|
.rename_lib_node(path, new_title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports renaming this node");
|
warn!(path, "no provider supports renaming this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -366,6 +484,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.resolve_tracks_into(path, chunk_tx)
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -383,6 +507,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if queues_owns(path) {
|
if queues_owns(path) {
|
||||||
return self.queues_provider()?.delete_lib_node(path).await;
|
return self.queues_provider()?.delete_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self.bookmarks_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports deleting this node");
|
warn!(path, "no provider supports deleting this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,26 +102,9 @@ impl QueueStore {
|
||||||
/// with a dot (hidden folders are invisible to listings), and the
|
/// with a dot (hidden folders are invisible to listings), and the
|
||||||
/// reserved [`CURRENT_QUEUE_NAME`].
|
/// reserved [`CURRENT_QUEUE_NAME`].
|
||||||
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
|
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
|
||||||
let name = name.trim();
|
// The shared fs-provider naming rules, with the auto-persisted
|
||||||
if name.is_empty() {
|
// queue's folder reserved.
|
||||||
return Err(SaveQueueError::InvalidName("must not be empty"));
|
fsdy::validate_folder_name(name, &[CURRENT_QUEUE_NAME]).map_err(SaveQueueError::InvalidName)
|
||||||
}
|
|
||||||
if name.contains(['/', '\\', '\0']) {
|
|
||||||
return Err(SaveQueueError::InvalidName(
|
|
||||||
"must not contain a path separator or NUL",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if name.starts_with('.') {
|
|
||||||
return Err(SaveQueueError::InvalidName(
|
|
||||||
"must not start with a dot (hidden folders are invisible)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if name == CURRENT_QUEUE_NAME {
|
|
||||||
return Err(SaveQueueError::InvalidName(
|
|
||||||
"is reserved for the automatically maintained queue",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves `snapshot` as the named queue, overwriting an existing one.
|
/// Saves `snapshot` as the named queue, overwriting an existing one.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
|
||||||
ClearQueueResponse, CreateLibraryNodeRequest, CreateLibraryNodeResponse,
|
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
|
||||||
DeleteLibraryNodeRequest, DeleteLibraryNodeResponse, GetLibraryNodeRequest,
|
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest,
|
||||||
GetLibraryNodeResponse, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse,
|
||||||
InitResponse, InsertRequest, InsertResponse, NextRequest, NextResponse, PrevRequest,
|
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
||||||
PrevResponse, QueueRequest, QueueResponse, RemoveRequest, RemoveResponse,
|
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
|
||||||
RenameLibraryNodeRequest, RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse,
|
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
|
||||||
RestartTrackRequest, RestartTrackResponse, SaveQueueRequest, SaveQueueResponse,
|
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
|
||||||
SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest,
|
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SetCurrentRequest,
|
||||||
ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest,
|
SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse,
|
||||||
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse,
|
||||||
|
ToggleShuffleRequest, ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
|
use crabidy_server::bookmark_store::CaptureError;
|
||||||
use crabidy_server::queue_store::SaveQueueError;
|
use crabidy_server::queue_store::SaveQueueError;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
@ -374,6 +376,53 @@ impl CrabidyService for RpcService {
|
||||||
Ok(Response::new(Box::pin(output_stream)))
|
Ok(Response::new(Box::pin(output_stream)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captures a queueable subtree as a bookmark via the provider loop
|
||||||
|
/// (structure-preserving snapshot under `/bookmarks/<name>`).
|
||||||
|
///
|
||||||
|
/// Error mapping is part of the contract: invalid name or
|
||||||
|
/// uncapturable source → `invalid_argument`; an over-cap subtree or
|
||||||
|
/// disabled bookmarks → `failed_precondition`; walk/write failures →
|
||||||
|
/// `internal`.
|
||||||
|
#[instrument(skip(self, request), fields(path, name))]
|
||||||
|
async fn capture_library_node(
|
||||||
|
&self,
|
||||||
|
request: Request<CaptureLibraryNodeRequest>,
|
||||||
|
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
||||||
|
let CaptureLibraryNodeRequest { path, name } = request.into_inner();
|
||||||
|
tracing::Span::current().record("path", path.as_str());
|
||||||
|
tracing::Span::current().record("name", name.as_str());
|
||||||
|
debug!("received capture_library_node request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.provider_tx
|
||||||
|
.send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
result_tx,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
Status::internal("provider unavailable")
|
||||||
|
})?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from provider: {err}");
|
||||||
|
Status::internal("provider did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
||||||
|
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
|
||||||
|
Err(Status::invalid_argument(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(err @ (CaptureError::TooLarge(_) | CaptureError::Disabled)) => {
|
||||||
|
Err(Status::failed_precondition(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("capture_library_node failed: {err}");
|
||||||
|
Err(Status::internal("cannot capture the subtree"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Saves the current queue under a name (persisted queues, visible as
|
/// Saves the current queue under a name (persisted queues, visible as
|
||||||
/// `/queues/<name>` in the library).
|
/// `/queues/<name>` in the library).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
324
fsdy/src/lib.rs
324
fsdy/src/lib.rs
|
|
@ -210,16 +210,15 @@ impl TrackFile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// File name for the serialized queue entry at zero-based `index`:
|
/// The shared name builder behind [`track_file_name`] and [`dir_name`]: a
|
||||||
/// a zero-padded four-digit one-based order prefix, a sanitized `title`,
|
/// zero-padded four-digit one-based order prefix plus a sanitized `title`.
|
||||||
/// and [`TRACK_FILE_SUFFIX`] — e.g. `0001 Bohemian Rhapsody.cbd-track.toml`.
|
|
||||||
///
|
///
|
||||||
/// The prefix makes the case-insensitive listing sort reproduce queue order
|
/// The prefix makes the case-insensitive listing sort reproduce the
|
||||||
/// (wrong beyond 9999 tracks — accepted, see
|
/// source's order (wrong beyond 9999 entries — accepted, see
|
||||||
/// `architecture/queue-persistence.md`). Sanitizing replaces path
|
/// `architecture/queue-persistence.md`). Sanitizing replaces path
|
||||||
/// separators and NUL, strips leading dots (hidden files are skipped by
|
/// separators and NUL, strips leading dots (hidden entries are skipped by
|
||||||
/// listings), and falls back to `track` for an empty result. Never panics.
|
/// listings), and falls back to `track` for an empty result. Never panics.
|
||||||
pub fn track_file_name(index: usize, title: &str) -> String {
|
fn ordered_name(index: usize, title: &str) -> String {
|
||||||
let sanitized: String = title
|
let sanitized: String = title
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
|
|
@ -236,7 +235,45 @@ pub fn track_file_name(index: usize, title: &str) -> String {
|
||||||
} else {
|
} else {
|
||||||
sanitized
|
sanitized
|
||||||
};
|
};
|
||||||
format!("{:04} {name}{TRACK_FILE_SUFFIX}", index + 1)
|
format!("{:04} {name}", index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File name for the serialized queue entry at zero-based `index`:
|
||||||
|
/// [`ordered_name`] plus [`TRACK_FILE_SUFFIX`] — e.g.
|
||||||
|
/// `0001 Bohemian Rhapsody.cbd-track.toml`.
|
||||||
|
pub fn track_file_name(index: usize, title: &str) -> String {
|
||||||
|
format!("{}{TRACK_FILE_SUFFIX}", ordered_name(index, title))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder name for the captured child node at zero-based `index`: the same
|
||||||
|
/// prefix and sanitizer as [`track_file_name`], without the suffix — e.g.
|
||||||
|
/// `0001 News of the World`.
|
||||||
|
pub fn dir_name(index: usize, title: &str) -> String {
|
||||||
|
ordered_name(index, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a user-supplied folder name for an fs-provider tree (bookmark
|
||||||
|
/// or saved-queue names, rename targets), returning the trimmed name.
|
||||||
|
///
|
||||||
|
/// Rejected (with a human-readable reason): empty after trimming,
|
||||||
|
/// containing `/`, `\` or NUL, starting with a dot (hidden folders are
|
||||||
|
/// invisible to listings), or matching one of `reserved` (e.g. the
|
||||||
|
/// auto-persisted `current` queue).
|
||||||
|
pub fn validate_folder_name<'a>(name: &'a str, reserved: &[&str]) -> Result<&'a str, &'static str> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("must not be empty");
|
||||||
|
}
|
||||||
|
if name.contains(['/', '\\', '\0']) {
|
||||||
|
return Err("must not contain a path separator or NUL");
|
||||||
|
}
|
||||||
|
if name.starts_with('.') {
|
||||||
|
return Err("must not start with a dot (hidden folders are invisible)");
|
||||||
|
}
|
||||||
|
if reserved.contains(&name) {
|
||||||
|
return Err("is a reserved name");
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The filesystem provider.
|
/// The filesystem provider.
|
||||||
|
|
@ -255,6 +292,13 @@ pub struct Client {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
/// First library path segment this instance owns, e.g. `/fs`.
|
/// First library path segment this instance owns, e.g. `/fs`.
|
||||||
provider_root: String,
|
provider_root: String,
|
||||||
|
/// Whether direct children of the instance root may be renamed and
|
||||||
|
/// deleted through the library (bookmarks, saved queues). Off by
|
||||||
|
/// default — `/fs` trees are managed with file tools.
|
||||||
|
editable_top_level: bool,
|
||||||
|
/// Folder names exempt from top-level editing (e.g. the auto-persisted
|
||||||
|
/// `current` queue) and rejected as rename targets.
|
||||||
|
reserved: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
|
|
@ -282,9 +326,23 @@ impl Client {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
root: disk_root,
|
root: disk_root,
|
||||||
provider_root: provider_root.to_string(),
|
provider_root: provider_root.to_string(),
|
||||||
|
editable_top_level: false,
|
||||||
|
reserved: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enables renaming and deleting the instance root's direct child
|
||||||
|
/// folders through the library (`is_editable`/`is_deletable` on the
|
||||||
|
/// root listing, [`ProviderClient::rename_lib_node`] /
|
||||||
|
/// [`ProviderClient::delete_lib_node`]). Names in `reserved` keep
|
||||||
|
/// their immutability and are rejected as rename targets. Deeper
|
||||||
|
/// levels always stay immutable (architecture/bookmarks.md D4).
|
||||||
|
pub fn with_editable_top_level(mut self, reserved: &[&str]) -> Self {
|
||||||
|
self.editable_top_level = true;
|
||||||
|
self.reserved = reserved.iter().map(|s| s.to_string()).collect();
|
||||||
|
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 {
|
||||||
|
|
@ -391,12 +449,20 @@ impl Client {
|
||||||
tracks.push(file.to_track(&child_lib));
|
tracks.push(file.to_track(&child_lib));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only an editable instance's direct root children are modifiable
|
||||||
|
// (architecture/bookmarks.md D4); reserved names (the auto-persisted
|
||||||
|
// `current` queue) keep their immutability.
|
||||||
|
let editable_here = self.editable_top_level && lib_path == self.provider_root;
|
||||||
let children = dir_names
|
let children = dir_names
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let child_lib =
|
let child_lib =
|
||||||
crabidy_core::join_path(lib_path, &crabidy_core::encode_segment(&name));
|
crabidy_core::join_path(lib_path, &crabidy_core::encode_segment(&name));
|
||||||
LibraryNodeChild::new(child_lib, name, true)
|
let editable = editable_here && !self.reserved.contains(&name);
|
||||||
|
let mut child = LibraryNodeChild::new(child_lib, name, true);
|
||||||
|
child.is_editable = editable;
|
||||||
|
child.is_deletable = editable;
|
||||||
|
child
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -419,6 +485,31 @@ impl Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The on-disk folder and decoded name of `path`, if it is a
|
||||||
|
/// modifiable top-level child of this instance: the editable option is
|
||||||
|
/// on, the path addresses a direct child folder of the instance root,
|
||||||
|
/// and the name is not reserved. Everything else is
|
||||||
|
/// [`ProviderError::NotSupported`] — exactly matching the
|
||||||
|
/// `is_editable`/`is_deletable` flags the listing advertised.
|
||||||
|
fn editable_child_dir(&self, path: &str) -> Result<(PathBuf, String), ProviderError> {
|
||||||
|
if !self.editable_top_level || self.is_track_path(path) {
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
let rest = path
|
||||||
|
.strip_prefix(self.provider_root.as_str())
|
||||||
|
.and_then(|rest| rest.strip_prefix('/'))
|
||||||
|
.ok_or(ProviderError::NotSupported)?;
|
||||||
|
if rest.is_empty() || rest.contains('/') {
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
let name = crabidy_core::decode_segment(rest);
|
||||||
|
if self.reserved.contains(&name) {
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
// The single traversal-validation site still guards the segment.
|
||||||
|
Ok((self.disk_path(path)?, name))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|
@ -559,18 +650,67 @@ impl ProviderClient for Client {
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Not supported — track files are renamed with normal file tools.
|
/// Renames a modifiable top-level folder (editable instances only, see
|
||||||
|
/// [`Self::with_editable_top_level`]); everything else is
|
||||||
|
/// [`ProviderError::NotSupported`].
|
||||||
|
///
|
||||||
|
/// The new title is validated like every store name (reserved names
|
||||||
|
/// rejected as targets); renaming onto an existing sibling is
|
||||||
|
/// [`ProviderError::InvalidInput`] — folders never merge. Renaming to
|
||||||
|
/// the current name is a no-op. Returns the renamed node at its new
|
||||||
|
/// path.
|
||||||
async fn rename_lib_node(
|
async fn rename_lib_node(
|
||||||
&self,
|
&self,
|
||||||
_path: &str,
|
path: &str,
|
||||||
_new_title: &str,
|
new_title: &str,
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
Err(ProviderError::NotSupported)
|
let (disk, name) = self.editable_child_dir(path)?;
|
||||||
|
let reserved: Vec<&str> = self.reserved.iter().map(String::as_str).collect();
|
||||||
|
let new_name = validate_folder_name(new_title, &reserved).map_err(|reason| {
|
||||||
|
warn!(path, new_title, "rejecting rename: name {reason}");
|
||||||
|
ProviderError::InvalidInput
|
||||||
|
})?;
|
||||||
|
let new_path =
|
||||||
|
crabidy_core::join_path(&self.provider_root, &crabidy_core::encode_segment(new_name));
|
||||||
|
if new_name == name {
|
||||||
|
return self.get_lib_node(&new_path).await;
|
||||||
|
}
|
||||||
|
// `validate_folder_name` rejected separators, NUL, and dot
|
||||||
|
// prefixes, so the target is a plain sibling name.
|
||||||
|
let target = self.root.join(new_name);
|
||||||
|
let taken = tokio::fs::try_exists(&target).await.map_err(|err| {
|
||||||
|
warn!(path, "cannot check rename target: {err}");
|
||||||
|
ProviderError::InternalError
|
||||||
|
})?;
|
||||||
|
if taken {
|
||||||
|
warn!(path, new_name, "rejecting rename onto an existing folder");
|
||||||
|
return Err(ProviderError::InvalidInput);
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&disk, &target).await.map_err(|err| {
|
||||||
|
warn!(path, "cannot rename folder: {err}");
|
||||||
|
ProviderError::InternalError
|
||||||
|
})?;
|
||||||
|
self.get_lib_node(&new_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Not supported — track files are deleted with normal file tools.
|
/// Deletes a modifiable top-level folder (editable instances only);
|
||||||
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
|
/// everything else is [`ProviderError::NotSupported`]. Idempotent: an
|
||||||
Err(ProviderError::NotSupported)
|
/// already-gone folder is a success. Returns the refreshed root
|
||||||
|
/// listing.
|
||||||
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let (disk, _name) = self.editable_child_dir(path)?;
|
||||||
|
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();
|
||||||
|
self.get_lib_node(&root).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// `resolve_tracks_into` deliberately keeps the default pre-order walk:
|
// `resolve_tracks_into` deliberately keeps the default pre-order walk:
|
||||||
|
|
@ -1034,6 +1174,158 @@ mod tests {
|
||||||
assert_eq!(reparsed.to_track("/queues/x.cbd-track.toml"), track);
|
assert_eq!(reparsed.to_track("/queues/x.cbd-track.toml"), track);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- mutable top level (bookmarks / saved queues) ---------------------
|
||||||
|
|
||||||
|
/// An editable instance over a root with two folders (one reserved) and
|
||||||
|
/// a nested subfolder.
|
||||||
|
async fn editable_client() -> (Client, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
fs::create_dir_all(dir.path().join("current")).expect("mkdir");
|
||||||
|
fs::create_dir_all(dir.path().join("road trip/album")).expect("mkdir");
|
||||||
|
let client = Client::new("/queues", dir.path().to_path_buf())
|
||||||
|
.expect("instance")
|
||||||
|
.with_editable_top_level(&["current"]);
|
||||||
|
(client, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editable_instances_flag_only_unreserved_top_level_folders() {
|
||||||
|
let (client, _dir) = editable_client().await;
|
||||||
|
let root = client.get_lib_node("/queues").await.expect("root");
|
||||||
|
let current = &root.children[0];
|
||||||
|
let saved = &root.children[1];
|
||||||
|
assert_eq!(current.title, "current");
|
||||||
|
assert!(!current.is_editable && !current.is_deletable, "reserved");
|
||||||
|
assert_eq!(saved.title, "road trip");
|
||||||
|
assert!(saved.is_editable && saved.is_deletable);
|
||||||
|
|
||||||
|
// Deeper levels stay immutable.
|
||||||
|
let nested = client
|
||||||
|
.get_lib_node("/queues/road%20trip")
|
||||||
|
.await
|
||||||
|
.expect("nested listing");
|
||||||
|
assert!(nested
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.all(|c| !c.is_editable && !c.is_deletable));
|
||||||
|
|
||||||
|
// Instances without the option (e.g. /fs) never set the flags.
|
||||||
|
let (immutable, _dir2) = client_with_root().await;
|
||||||
|
fs::create_dir(_dir2.path().join("music")).expect("mkdir");
|
||||||
|
let root = immutable.get_lib_node("/fs").await.expect("fs root");
|
||||||
|
assert!(root
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.all(|c| !c.is_editable && !c.is_deletable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_moves_a_top_level_folder() {
|
||||||
|
let (client, dir) = editable_client().await;
|
||||||
|
let node = client
|
||||||
|
.rename_lib_node("/queues/road%20trip", "hiking")
|
||||||
|
.await
|
||||||
|
.expect("rename");
|
||||||
|
assert_eq!(node.path, "/queues/hiking");
|
||||||
|
assert_eq!(node.title, "hiking");
|
||||||
|
assert!(dir.path().join("hiking/album").is_dir(), "content moved");
|
||||||
|
assert!(!dir.path().join("road trip").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_rejects_reserved_invalid_and_colliding_targets() {
|
||||||
|
let (client, _dir) = editable_client().await;
|
||||||
|
// The reserved folder is not editable.
|
||||||
|
let err = client
|
||||||
|
.rename_lib_node("/queues/current", "x")
|
||||||
|
.await
|
||||||
|
.expect_err("reserved");
|
||||||
|
assert_eq!(err, ProviderError::NotSupported);
|
||||||
|
// Nested folders are not editable.
|
||||||
|
let err = client
|
||||||
|
.rename_lib_node("/queues/road%20trip/album", "x")
|
||||||
|
.await
|
||||||
|
.expect_err("nested");
|
||||||
|
assert_eq!(err, ProviderError::NotSupported);
|
||||||
|
// Bad new titles are invalid input: empty, separators, hidden,
|
||||||
|
// reserved target.
|
||||||
|
for bad in ["", " ", "a/b", ".hidden", "current"] {
|
||||||
|
let err = client
|
||||||
|
.rename_lib_node("/queues/road%20trip", bad)
|
||||||
|
.await
|
||||||
|
.expect_err(bad);
|
||||||
|
assert_eq!(err, ProviderError::InvalidInput, "{bad:?}");
|
||||||
|
}
|
||||||
|
// Renaming onto an existing sibling never merges.
|
||||||
|
fs::create_dir(_dir.path().join("taken")).expect("mkdir");
|
||||||
|
let err = client
|
||||||
|
.rename_lib_node("/queues/road%20trip", "taken")
|
||||||
|
.await
|
||||||
|
.expect_err("collision");
|
||||||
|
assert_eq!(err, ProviderError::InvalidInput);
|
||||||
|
// Immutable instances keep the old contract.
|
||||||
|
let (immutable, _dir2) = client_with_root().await;
|
||||||
|
fs::create_dir(_dir2.path().join("music")).expect("mkdir");
|
||||||
|
let err = immutable
|
||||||
|
.rename_lib_node("/fs/music", "x")
|
||||||
|
.await
|
||||||
|
.expect_err("immutable instance");
|
||||||
|
assert_eq!(err, ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_removes_top_level_folders_idempotently() {
|
||||||
|
let (client, dir) = editable_client().await;
|
||||||
|
let parent = client
|
||||||
|
.delete_lib_node("/queues/road%20trip")
|
||||||
|
.await
|
||||||
|
.expect("delete");
|
||||||
|
assert_eq!(parent.path, "/queues");
|
||||||
|
assert!(!dir.path().join("road trip").exists());
|
||||||
|
assert_eq!(parent.children.len(), 1, "only `current` remains");
|
||||||
|
// Idempotent: deleting an already-gone folder succeeds.
|
||||||
|
client
|
||||||
|
.delete_lib_node("/queues/road%20trip")
|
||||||
|
.await
|
||||||
|
.expect("idempotent delete");
|
||||||
|
// Reserved and nested folders are not deletable.
|
||||||
|
for undeletable in ["/queues/current", "/queues/current/nested"] {
|
||||||
|
let err = client
|
||||||
|
.delete_lib_node(undeletable)
|
||||||
|
.await
|
||||||
|
.expect_err(undeletable);
|
||||||
|
assert_eq!(err, ProviderError::NotSupported, "{undeletable}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared naming ----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dir_names_share_the_track_file_sanitizer() {
|
||||||
|
assert_eq!(dir_name(0, "News of the World"), "0001 News of the World");
|
||||||
|
assert_eq!(dir_name(10, "x"), "0011 x");
|
||||||
|
let tricky = dir_name(1, "../a/b\\c\0");
|
||||||
|
assert!(!tricky.contains(['/', '\\', '\0']), "{tricky}");
|
||||||
|
assert!(!tricky.starts_with('.'), "{tricky}");
|
||||||
|
assert_eq!(dir_name(2, ""), "0003 track");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folder_name_validation_trims_and_rejects() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_folder_name(" hiking ", &["current"]),
|
||||||
|
Ok("hiking")
|
||||||
|
);
|
||||||
|
for bad in ["", " ", "a/b", "a\\b", "a\0b", ".hidden", "current"] {
|
||||||
|
assert!(
|
||||||
|
validate_folder_name(bad, &["current"]).is_err(),
|
||||||
|
"{bad:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Reservation applies to the trimmed form.
|
||||||
|
assert!(validate_folder_name(" current ", &["current"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn track_file_names_sort_in_queue_order_and_stay_plain() {
|
fn track_file_names_sort_in_queue_order_and_stay_plain() {
|
||||||
// Zero-padded one-based prefix, suffix appended.
|
// Zero-padded one-based prefix, suffix appended.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Plan: bookmarks
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs` /
|
||||||
|
`crabidy-server/src/bookmark_store.rs` and/or gates in
|
||||||
|
`quality/bookmarks.md`). Stubs, the proto rpc, and orchestrator wiring
|
||||||
|
exist; the new fsdy/bookmark-store tests fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — fsdy shared naming.** `dir_name` (prefix + shared sanitizer,
|
||||||
|
no suffix; refactor `track_file_name` onto one helper) and
|
||||||
|
`validate_folder_name` (trim, reject separators/NUL/hidden/reserved).
|
||||||
|
Verifies: `dir_names_share_the_track_file_sanitizer`,
|
||||||
|
`folder_name_validation_trims_and_rejects`; gate "no second naming
|
||||||
|
scheme".
|
||||||
|
- [x] **T2 — fsdy mutable top level.** Flags on the editable-instance root
|
||||||
|
listing (skip reserved); `rename_lib_node`/`delete_lib_node`
|
||||||
|
implementations gated on option + direct child + not reserved
|
||||||
|
(`NotSupported` otherwise); rename validates via
|
||||||
|
`validate_folder_name`, refuses collisions (`InvalidInput`), returns
|
||||||
|
the renamed node; delete is idempotent and returns the root listing.
|
||||||
|
Verifies: `editable_instances_flag_only_unreserved_top_level_folders`,
|
||||||
|
`rename_moves_a_top_level_folder`,
|
||||||
|
`rename_rejects_reserved_invalid_and_colliding_targets`,
|
||||||
|
`delete_removes_top_level_folders_idempotently`; gates "Mutable top
|
||||||
|
level".
|
||||||
|
- [x] **T3 — queue_store reuse.** `QueueStore::validate_name` delegates to
|
||||||
|
`fsdy::validate_folder_name` with `current` reserved (behavior
|
||||||
|
unchanged — existing tests must stay green).
|
||||||
|
- [x] **T4 — BookmarkStore capture.** `open`, `capture_with_caps`
|
||||||
|
(iterative pre-order walk over `get_lib_node` /
|
||||||
|
`get_metadata_for_track`, mirrored dirs + link files, caps, temp
|
||||||
|
cleanup on every failure, tmp-and-swap). Verifies: all
|
||||||
|
`bookmark_store::tests`; gates "Capture".
|
||||||
|
- [x] **T5 — TUI capture flow.** `Action::LibraryCaptureNode` (`w`,
|
||||||
|
`Scope::Library`), `Library::selected_queueable()` (bare selection,
|
||||||
|
queueable, path + title), `InputPurpose::Capture { path }` prefilled
|
||||||
|
with the title, `MessageFromUi::CaptureNode { path, name }`,
|
||||||
|
orchestrator arm → new `rpc::capture_library_node` (log failures, keep
|
||||||
|
polling). TUI tests: binding lookup, overlay gating + prefill, submit
|
||||||
|
message. Verifies: new `cbd-tui` tests; gates "TUI".
|
||||||
|
- [x] **T6 — full verification.** Whole workspace suite green;
|
||||||
|
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||||
|
`quality/bookmarks.md` and tick it; no `todo!()` left.
|
||||||
|
- [x] **T7 — live smoke test.** Capture a real Tidal artist subtree into a
|
||||||
|
temp store through the provider layer, browse it through a
|
||||||
|
`/bookmarks` instance, rename it, resolve an album, and fetch a stream
|
||||||
|
URL for one captured link — remove any temporary probe afterwards.
|
||||||
|
Verifies: end-to-end D1/D2/D4 behavior outside unit scope.
|
||||||
|
- [x] **T8 — docs.** `plan/summary.md` section incl. deviations; reconcile
|
||||||
|
`architecture/queue-persistence.md` D8 (saved queues now
|
||||||
|
renamable/deletable) and `architecture/bookmarks.md` if the
|
||||||
|
implementation diverged.
|
||||||
|
|
@ -1,5 +1,60 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## bookmarks (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/bookmarks.md`: `w` on a queueable library selection now
|
||||||
|
captures the whole subtree as a **bookmark** — a structure-preserving
|
||||||
|
snapshot under `<config>/crabidy/bookmarks/<name>/`, mounted read-only at
|
||||||
|
`/bookmarks` by a third `fsdy` instance. The capture runs on the
|
||||||
|
orchestrator (a spawned task walking `get_lib_node` iteratively across
|
||||||
|
any provider): every child node becomes an order-prefixed folder
|
||||||
|
(`fsdy::dir_name`, sharing the queue entries' sanitizer), every track an
|
||||||
|
order-prefixed link file, so the case-insensitive listing reproduces the
|
||||||
|
source order and replaying is plain fs-provider behavior. Caps (1 000
|
||||||
|
dirs / 20 000 tracks) abort cleanly with the temp folder removed; writes
|
||||||
|
are tmp-and-swap; re-capturing a name overwrites it. The wire gained one
|
||||||
|
additive rpc, `CaptureLibraryNode(path, name)` (invalid name/source →
|
||||||
|
`invalid_argument`, over-cap/disabled → `failed_precondition`). The TUI
|
||||||
|
opens the existing input overlay prefilled with the selection's title
|
||||||
|
(`bookmark`), gated on a queueable bare selection.
|
||||||
|
|
||||||
|
On top, `fsdy::Client` gained `with_editable_top_level(reserved)`:
|
||||||
|
editable instances mark their root's child folders
|
||||||
|
`is_editable`/`is_deletable` and implement rename (no-merge, validated
|
||||||
|
titles, returns the renamed node) and delete (idempotent, returns the
|
||||||
|
refreshed root). Applied to `/bookmarks` (nothing reserved) **and
|
||||||
|
`/queues`** (reserved: `current`) — saved queues are now renamable and
|
||||||
|
deletable through the existing `e`/`d` flows with zero TUI changes.
|
||||||
|
`/fs` stays immutable. All 130 workspace tests green (6 new in `fsdy`, 7
|
||||||
|
in `bookmark_store`, 2 TUI); every gate in `quality/bookmarks.md`
|
||||||
|
checked. A temporary live probe (removed after passing) captured a
|
||||||
|
19-track album from the live Tidal API, browsed it with editable flags,
|
||||||
|
renamed it, resolved it in order, and fetched a stream URL for a
|
||||||
|
captured link.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions
|
||||||
|
are recorded in `architecture/bookmarks.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (bookmarks)
|
||||||
|
|
||||||
|
- **Capture is all-or-nothing**: any provider or write failure mid-walk
|
||||||
|
aborts the whole capture (temp folder removed) instead of skipping the
|
||||||
|
failing subtree with a warning — a bookmark that *looks* complete must
|
||||||
|
*be* complete. The architecture only specified the unreadable-*root*
|
||||||
|
case; this extends it to every node.
|
||||||
|
- **Rename to the current name is a no-op success** (returns the node),
|
||||||
|
not a collision error — the target "exists" only because it is the
|
||||||
|
source.
|
||||||
|
- **Rename targets don't pass `disk_path`**: the new folder name is
|
||||||
|
validated by `validate_folder_name` (no separators, NUL, or leading
|
||||||
|
dots), which makes it a plain sibling name by construction; the
|
||||||
|
traversal gate still covers every client-supplied *path*.
|
||||||
|
- **`track_file_name` was refactored onto a shared `ordered_name`**
|
||||||
|
helper rather than duplicated for `dir_name` (planned as "shared
|
||||||
|
sanitizer", realized as one function).
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR`; no repo change.
|
||||||
|
|
||||||
## queue-persistence (2026-07-21)
|
## queue-persistence (2026-07-21)
|
||||||
|
|
||||||
Built per `plan/queue-persistence.md`: queues now survive server restarts,
|
Built per `plan/queue-persistence.md`: queues now survive server restarts,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Quality gates: bookmarks
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automatic tests
|
||||||
|
(`fsdy/src/lib.rs`, `crabidy-server/src/bookmark_store.rs`, plus the TUI
|
||||||
|
tests added during implementation). Each gate is pass/fail by reading the
|
||||||
|
code.
|
||||||
|
|
||||||
|
## Capture
|
||||||
|
|
||||||
|
- [x] The walk is iterative (worklist), never recursive — a deep provider
|
||||||
|
tree cannot overflow the stack.
|
||||||
|
- [x] The caps bound *total* directories and tracks and abort with a typed
|
||||||
|
error; the temp folder is removed on every failure path (cap, walk
|
||||||
|
error, write error).
|
||||||
|
- [x] Captures write through the same primitives as queues
|
||||||
|
(`TrackFile::from_track`, `track_file_name`, and `dir_name` sharing one
|
||||||
|
sanitizer) — no second serialization or naming scheme.
|
||||||
|
- [x] Whole-bookmark writes are tmp-and-swap; overwriting an existing
|
||||||
|
bookmark never leaves a mix of old and new entries.
|
||||||
|
- [x] A source path that is a track captures as a folder with one file; an
|
||||||
|
unreadable source is `BadSource`, not a panic or an empty bookmark.
|
||||||
|
- [x] The capture runs on a spawned task; the orchestrator loop keeps
|
||||||
|
serving commands during a large walk.
|
||||||
|
- [x] No file contents in logs (paths, names, and counts only).
|
||||||
|
|
||||||
|
## Mutable top level (fsdy)
|
||||||
|
|
||||||
|
- [x] The `is_editable`/`is_deletable` flags appear **only** on direct
|
||||||
|
child folders of an editable instance's root, never on reserved names,
|
||||||
|
nested nodes, tracks, or immutable instances.
|
||||||
|
- [x] `rename_lib_node`/`delete_lib_node` enforce the same gate they
|
||||||
|
advertise: option on + direct child + not reserved; everything else is
|
||||||
|
`NotSupported` (matching the flags a client saw).
|
||||||
|
- [x] Rename validates the new title with the shared
|
||||||
|
`validate_folder_name` (reserved names rejected as targets) and never
|
||||||
|
merges onto an existing sibling (`InvalidInput`).
|
||||||
|
- [x] Delete is idempotent (already gone → success) and returns the
|
||||||
|
refreshed root listing; rename returns the renamed node at its new
|
||||||
|
path.
|
||||||
|
- [x] The `/queues` instance reserves `current`; `/bookmarks` reserves
|
||||||
|
nothing; `/fs` stays fully immutable.
|
||||||
|
- [x] Path traversal validation still happens only in `disk_path`; rename
|
||||||
|
and delete go through it for every path they touch.
|
||||||
|
|
||||||
|
## RPC and orchestrator
|
||||||
|
|
||||||
|
- [x] `capture_library_node` maps errors: invalid name/source →
|
||||||
|
`invalid_argument`, over-cap or disabled → `failed_precondition`,
|
||||||
|
walk/write failures → `internal`.
|
||||||
|
- [x] The orchestrator routes `/bookmarks` in every `ProviderClient`
|
||||||
|
method (same completeness as `/queues`), and `get_lib_root` lists the
|
||||||
|
`bookmarks` child only when the instance exists.
|
||||||
|
- [x] Bookmarks init is non-fatal: no config dir or an unopenable store
|
||||||
|
disables capture and the `/bookmarks` mount, never the server.
|
||||||
|
|
||||||
|
## TUI
|
||||||
|
|
||||||
|
- [x] `w` is bound in `Scope::Library` (queue's `w` untouched), has a help
|
||||||
|
description, and passes the bindings-table invariant tests unchanged.
|
||||||
|
- [x] The capture overlay opens only for a queueable bare selection,
|
||||||
|
prefilled with the selection's title; marks are ignored.
|
||||||
|
- [x] `MessageFromUi::CaptureNode` reaches the new rpc; a failed capture
|
||||||
|
is logged and never tears down the poll loop.
|
||||||
|
- [x] Renaming/deleting bookmark and saved-queue folders works through the
|
||||||
|
existing `e`/`d` flows with no TUI code changes.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||||
|
whole workspace; all tests green.
|
||||||
|
- [x] `architecture/queue-persistence.md` D8 reconciled (rename/delete of
|
||||||
|
saved queues is no longer out of scope).
|
||||||
Loading…
Reference in New Issue