crabidy/architecture/bookmarks.md

8.0 KiB

Bookmarks: capturing library subtrees

Superseded by crabidy-store.md: /bookmarks is folded into the single /crabidy provider; a w-save writes link tomls into a /crabidy/<name> folder. Kept for the link-vs-store rationale.

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

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.