# The `crabidy` provider and the content-addressed store ## Context and problem statement Today the server exposes three hand-managed subtrees — `/queues`, `/bookmarks`, `/captures` — each a separate `fsdy::Client` instance rooted under `~/.config/crabidy/`. Bookmarks (`w`) write *link* tomls; captures (`W`) download audio next to each toml (`capture-deletion.md`, `incremental-captures.md`). This has three problems the user wants fixed green-field (no data migration): 1. **No de-duplication.** Capturing the same track from two places (a playlist and a search, or two saved queues) downloads and stores the audio twice. Re-capturing after a reorder re-downloads everything. 2. **Audio lives next to metadata.** Deleting a capture folder must carefully remove downloaded audio from disk behind a confirmation (`capture-deletion.md`), because the audio is only referenced from that one folder. This couples deletion to expensive-payload bookkeeping. 3. **Three UI concepts** (queues, bookmarks, captures) for what the user thinks of as "my crabidy stuff." They want one provider. This design replaces all three with **one filesystem provider, `/crabidy`**, whose track tomls *link into a single content-addressed store* of playable files. Captures de-duplicate by provider identity and by content hash; deletion becomes a plain toml removal that never touches the store. This **supersedes** `bookmarks.md`, `captures.md`, `capture-deletion.md`, and the on-disk/resumption parts of `incremental-captures.md` (the skipped-track and progress-stream parts of that doc survive; see D9). ## Assumptions (confirmed by the request) - **Green-field.** No migration of existing `~/.config/crabidy/{queues, bookmarks,captures}` data. On first run the new locations are simply empty. - **Two roots, split by XDG kind.** The store (playable audio + its sidecars) is *data* → `~/.local/share/crabidy/` (`dirs::data_dir()`). The tomls (the `/crabidy` tree) are *state* → `~/.local/state/crabidy/` (`dirs::state_dir()`). - **Single server, single writer.** One process owns both roots; capture mutations are serialized. No cross-host concurrent writers. - **The store never shrinks automatically.** Deleting a toml never deletes store audio (D7). Orphaned store entries are accepted; a garbage collector is future work (D10). - **`current` stays special.** The live queue is still mirrored to a reserved, user-untouchable folder — now `/crabidy/current`, flat as before. ## D1 — One provider: `/crabidy` `ProviderOrchestrator` drops the `queues`/`bookmarks`/`captures` fields and gains one `crabidy` field: an `fsdy::Client` rooted at `~/.local/state/crabidy/`, mounted at `/crabidy`, built `with_editable_top_level(&["current"])` (top-level saves are renamable/deletable; `current` is reserved) `.with_downloadable_nodes()` `.with_deletable_tree()` (every node deletes directly — see D7). A companion writer, `CrabidyStore`, owns *both* roots and all mutation. - Top-level folders under `/crabidy` are **user saves**, each created by `w`/`W`. - **Saved queues are flat**; **saved library subtrees preserve their structure** (falls out of walking the source — a flat queue yields a flat save). - `/crabidy/current` is the live-queue mirror the playback loop keeps in sync (replaces `/queues/current`). It is flat and reserved. Non-toml files in a save folder (e.g. client log files that also land in `~/.local/state/crabidy/`) are ignored by listing, as today — the provider only surfaces subdirectories and `*.cbd-track.toml` files. (Optional tidy-up, not required: move client logs to `~/.local/state/crabidy/logs/`.) ```d2 direction: right orchestrator: ProviderOrchestrator (root, path-routed) { tidal youtube fs crabidy: /crabidy (fsdy::Client, read + delete) } state: "~/.local/state/crabidy/\n(toml tree)" { shape: cylinder } share: { label: "~/.local/share/crabidy/\ncontent store: audio + sidecars" shape: cylinder } store_writer: CrabidyStore (single writer, owns both roots) { index: "StoreIndex\n(provider-id → entry,\nhash → entry)" } orchestrator.crabidy -> state: lists tomls orchestrator.crabidy -> share: resolves Store playables store_writer -> state: writes save folders / track tomls store_writer -> share: writes audio + sidecars store_writer.index -> share: derived by scanning sidecars at open ``` ## D2 — The store and its sidecars `~/.local/share/crabidy/` is a **flat** directory. Each unique playable is a pair: - `` — the audio file, named after the source's natural name (a local file's basename, else a sanitized `.<ext>` with the extension from the download's `Content-Type`/URL). On name collision with *different* content, append a numeral: `song.flac`, `song (2).flac`, … (identical content never reaches naming — it de-dupes first, D4). - `<name>.cbd-store.toml` — the sidecar, the store entry's metadata: ```toml # song.flac.cbd-store.toml hash = "blake3:1f0c…" # content hash of the audio file [[provider]] # one entry per provider-id that maps here provider = "tidal" # provider name (path root of the source) id = "125169484" # provider-internal id (see D3) title = "Bohemian Rhapsody" artist = "Queen" duration = 355 aliases = ["Bohemian Rhapsody (Remastered)"] # other titles for this id [provider.album] title = "A Night at the Opera" release_date = "1975-11-21" [[provider]] # same content reached via a second identity provider = "youtube" id = "fJ9rUzIMcZQ" title = "Queen – Bohemian Rhapsody (Official Video)" ``` The **sidecars are the single source of truth** — there is no separate persisted index file. `CrabidyStore` builds an in-memory `StoreIndex` by scanning `*.cbd-store.toml` at open and updates it on every write: - `by_provider_id: HashMap<(provider, id), StoreRef>` - `by_hash: HashMap<Hash, StoreRef>` A `StoreRef` is the store `<name>` (which is the toml link target and the sidecar key). Lookups are O(1). > **"grep without shelling out."** The request describes searching sidecars > for a provider id / hash like ripgrep, in-process. The `StoreIndex` *is* it, > memoized: it is derived by reading the sidecars in-process at open (no shell, > no external index), so the store stays self-describing. A live > content-scan-then-parse (via the `grep-searcher` crate) was considered as the > literal realization; rejected because an in-memory map built once is simpler, > strictly faster for repeated captures in a session, and needs no new > dependency. If the store ever grows beyond memory, a lazy content-scan is the > fallback (D10). ## D3 — Provider identity on the wire The store keys on a **provider-internal id** — "the id that clearly identifies the item inside the provider," which the path cannot be (the same item is reachable via a playlist, a search, an album…). Today no such id exists; identity is the path string. We add it to the wire model: - `Track.provider_item_id` (proto field 7, `string`) — set by the owning provider when it produces a `Track`. Empty when unknown. - **tidal** → the numeric track id. - **youtube** → the video id. - **fs** → the source file's canonical absolute path (two `/fs` tomls pointing at the same file share an id → they de-dupe). - **crabidy** (already store-backed) → the store `<name>`; used only to detect "already captured" (D5). - The **provider name** is the first path segment of the (resolved) source track path — `to_track` already rewrites a link track's `path` to its target, so a queued link routes to the real provider and carries that provider's id. For the capture indicator (D8) we also add: - `Track.is_captured` (field 8, `bool`) — the server sets it at listing time when the store index has an entry for this track's `(provider, id)` **or** its playable is already a store link. Cheap (one hashmap lookup) and works while browsing *any* provider, so you can see what you have captured. - `LibraryNode.is_captured` (field 10) and `LibraryNodeChild.is_captured` (field 8) — a node is captured iff all its tracks and child nodes are captured (D8 covers how the `/crabidy` provider computes this cheaply). ## D4 — Capturing one track: the de-dup flow `store = true` means the track links into the store; `link` means a bookmark link to the source provider. Capture (`W`) produces store links; bookmark (`w`) produces plain links and never touches the store. For each source track a capture walk visits, in order: ```d2 direction: down start: "resolve source track\n(provider, id, natural name)" already: "already store-backed?\n(Store playable, or fs file under the store root)" byid: "index.by_provider_id[(provider,id)] ?" getbytes: "obtain bytes\n(download → temp, or local file)" byhash: "index.by_hash[hash(bytes)] ?" newentry: "NEW: copy into store\n(+numeral) + write sidecar" addid: "add provider to sidecar,\ndiscard the temp copy" writetoml: "write track toml with playable.store = <name>" start -> already already -> writetoml: "yes → reuse target store name (no-op copy)" already -> byid: "no" byid -> writetoml: "HIT → reuse; record alias if title differs" byid -> getbytes: "MISS" getbytes -> byhash byhash -> addid: "HIT (same content, new identity)" byhash -> newentry: "MISS" addid -> writetoml newentry -> writetoml ``` 1. **Already store-backed?** If the source track's playable is a `Store` link, or it is an `/fs` file whose path is already under the store root, there is nothing to fetch — the save's toml links to the same store `<name>`. (This is the "capture on an already-captured fs item → do nothing" case.) 2. **Provider-id lookup.** `index.by_provider_id[(provider, id)]` — a hit means we already have this exact provider item. Point the save's toml at that store entry. If the current title differs from the stored one, append it to that entry's `aliases`. **No download.** (Common re-capture path — makes redoing a save cheap.) 3. **Miss → obtain bytes.** Streamed source: windowed HTTP download to a temp file (unchanged mechanics). Local source (`/fs` pointing at a normal file, not under the store): the file is the bytes. Hash the bytes (blake3). 4. **Hash lookup.** `index.by_hash[hash]` — a hit means identical content is already stored under some other identity. **Add** a `[[provider]]` entry to that sidecar, **discard** the temp download (or do not copy the fs file), point the toml at the existing store name. No duplicate. 5. **Miss → new store entry.** Choose a store name from the natural name (+numeral on collision), move the temp file (or copy the fs file) into the store, write the sidecar with `hash` and the first `[[provider]]` entry, update the index. The `/fs` case *copies* — the original music-folder file stays put. The byte budget (`DOWNLOAD_CAPS.max_bytes`) still counts bytes fetched *this run* (hits cost nothing), so dedup makes big saves cheaper, never starves them. ## D5 — Save (`w`/`W`) and conflict handling A **save** takes a *source* (a live-queue snapshot or a library-node path) and a *mode* (`Link` for `w`, `Capture` for `W`) and writes a new top-level folder `/crabidy/<name>`: - **`w`** on a library node or the queue → folder of **link** tomls (`fsdy::TrackFile::from_track`), the bookmark semantics, no store, no audio. - **`W`** on a library node or the queue → `w` **plus** run the D4 capture per track; tomls carry `playable.store`. Works on both library nodes and the queue. **Conflict = refuse.** If `/crabidy/<name>` already exists, do nothing and warn `name "<name>" already exists`; the user deletes the old folder and saves again. This replaces the old overwrite (bookmark tmp-swap) and merge-by-name (capture resume). The save is still built in a hidden `.tmp-<name>` sibling and swapped in atomically, so a crashed/failed run leaves **no** blocking partial folder and the name stays free to retry — while any audio already committed to the store persists and makes the retry fast via D4. Resumability thus moves from the folder to the store; the folder is all-or-nothing. `current` is exempt: the playback loop overwrites it on every queue change; the user cannot save over the reserved name `current`. ## D6 — RPC surface - **`CaptureLibraryNode(path, name, download)`** stays and now covers *all four* gestures: `w`/`W` on a library node (`path = /tidal/...`), and `w`/`W` on the queue (`path = /crabidy/current`). `download=false` → `Link`, `true` → `Capture`. Validation errors (bad name, conflict, source not downloadable) return synchronously; progress streams via the existing `CaptureProgress` update (unchanged, D9). - **`SaveQueue(name)` is removed.** Queue save/capture both go through `CaptureLibraryNode` on `/crabidy/current`. The TUI's `w`-on-queue (`QueueSaveAs`) and `W`-on-queue (`QueueDownloadCapture`) both emit a `CaptureNode{ path: "/crabidy/current", name, download }`. - `DeleteLibraryNode(path)` unchanged in shape; behavior simplified (D7). ## D7 — Deletion Deletion on `/crabidy` (the only writable fs provider) **goes through directly, no confirmation, and never touches the store**: - Delete a track → remove its `.cbd-track.toml` only. - Delete a folder → `remove_dir_all` of the toml folder only. The existing `fsdy::delete_track_file` guard — "only delete the referenced audio if it is contained under the instance root" — already makes this safe: a `Store` playable resolves under `~/.local/share/…`, which is *outside* the `/crabidy` toml root at `~/.local/state/…`, so the audio is never deleted. And `w`-saves are links with no audio at all. Consequences: drop the TUI's `delete_needs_confirmation` / `ConfirmDelete` path (the whole `capture-deletion.md` confirmation feature is gone — nothing expensive is destroyed anymore) and drop `with_deletable_tree`'s audio-removal branch usage for this provider (folder/toml removal remains). ## D8 — UI: collapse to one provider, mark captured nodes - `/queues`, `/bookmarks`, `/captures` disappear from the root; one `/crabidy` child appears (title `crabidy`). Inside it, `w`-saves (links) and `W`-saves (store-backed) coexist, distinguished by the captured marker. - **Captured marker:** a captured row is prefixed with `|` as the *first character of the row* (before the selection padding), e.g. `|Bohemian…`. - A **track** row is captured per `Track.is_captured` (D3): store-backed, or its `(provider, id)` is in the store index — visible even while browsing tidal. - A **node/child** row is captured iff all its tracks and child nodes are captured. The `/crabidy` provider computes a node's own `is_captured` when it lists it (it reads every track toml anyway → all `Store`?). To mark child *folders* in a parent listing without deep recursion, a save records its mode in a one-line marker at the save root written by `w`/`W`; nested folders under a `W`-save are captured by construction. Exact recursion depth is an implementation detail (see plan) — the invariant is "captured = fully local." - The existing capture **progress** lines (`capturing <name> 12/34 …`) stay as-is. ## D9 — What carries over from `incremental-captures.md` Kept: the `Skipped` playable (D1 there), skipped tracks in the queue and playback skipping them (D3), the `CaptureProgress` stream and accept-then-stream RPC (D4), and the focused-selection contrast fix (D7). A source that genuinely cannot be fetched still writes a skipped toml. What changes: the *store write mode* (audio now goes to the shared store, tomls carry `store`, dedup per D4) and *resume semantics* (folder is atomic; store provides the savings, per D5). ## D10 — Out of scope / future - **Store garbage collection.** Nothing reclaims store entries whose last referencing toml was deleted. A future GC would scan all `/crabidy` tomls for live `store` names and remove unreferenced pairs; needs its own design. - **Lazy content-scan** instead of the in-memory index, for stores too large to index in memory (D2). - **Cross-provider captured marking of folders** (e.g. a tidal album shown captured) beyond the cheap track-level lookup. - **Cancelling a running capture** (unchanged from prior scope). ## Risks - **Wasted download on a hash-only match** (provider-id missed but content is identical): we download, then discard. Unavoidable for content dedup; the provider-id path avoids it in the common case. - **Natural-name collisions** across unrelated tracks are handled by the numeral suffix; the store name is opaque to users (only the toml title shows in UI). - **Index/disk skew** if something outside the server edits the store: the index is rebuilt at every start, and the server is the sole writer, so skew is bounded to a single run — acceptable. - **blake3 dependency** added (fast, maintained, no C toolchain). Alternative `sha2` rejected for speed; hashing whole tracks is on the capture hot path.