189 lines
8.0 KiB
Markdown
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.
|