crabidy/architecture/bookmarks.md

189 lines
8.0 KiB
Markdown

# 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
```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.