7.8 KiB
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::Clientis instance-mountable since queue persistence (Client::new(provider_root, disk_root)); a third instance is cheap.- The TUI already routes
e/dthroughis_editable/is_deletableflags onLibraryNodeChildinto the existingRenameLibraryNode/DeleteLibraryNoderpcs, 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_nodethrough theProviderOrchestrator(any provider reachable), andTrackFile::from_track+track_file_namefrom queue persistence for the leaves. wis unbound in the TUI'sLibraryscope; 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.tomlvia the existingTrackFile::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_editableandis_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 isInvalidInput(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
/bookmarksroot (%) — bookmarks are created from the source tree.
Structure
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
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.