17 KiB
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):
- 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.
- 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. - 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/crabidytree) 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).
currentstays 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
/crabidyare user saves, each created byw/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/currentis 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/.)
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:
<name>— the audio file, named after the source's natural name (a local file's basename, else a sanitized<title>.<ext>with the extension from the download'sContent-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:
# 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
StoreIndexis 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 thegrep-searchercrate) 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 aTrack. Empty when unknown.- tidal → the numeric track id.
- youtube → the video id.
- fs → the source file's canonical absolute path (two
/fstomls 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_trackalready rewrites a link track'spathto 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) andLibraryNodeChild.is_captured(field 8) — a node is captured iff all its tracks and child nodes are captured (D8 covers how the/crabidyprovider 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:
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
- Already store-backed? If the source track's playable is a
Storelink, or it is an/fsfile 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.) - 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'saliases. No download. (Common re-capture path — makes redoing a save cheap.) - Miss → obtain bytes. Streamed source: windowed HTTP download to a temp
file (unchanged mechanics). Local source (
/fspointing at a normal file, not under the store): the file is the bytes. Hash the bytes (blake3). - 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. - 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
hashand the first[[provider]]entry, update the index. The/fscase 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>:
won a library node or the queue → folder of link tomls (fsdy::TrackFile::from_track), the bookmark semantics, no store, no audio.Won a library node or the queue →wplus run the D4 capture per track; tomls carryplayable.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/Won a library node (path = /tidal/...), andw/Won the queue (path = /crabidy/current).download=false→Link,true→Capture. Validation errors (bad name, conflict, source not downloadable) return synchronously; progress streams via the existingCaptureProgressupdate (unchanged, D9).SaveQueue(name)is retained (this deviates from the original plan to remove it — seeplan/summary.md). It is the queuewgesture and is reimplemented server-side as a link save of the live queue into/crabidy/<name>(viaCrabidyStore::save_snapshot). QueueW(QueueDownloadCapture) goes throughCaptureLibraryNodeon/crabidy/currentwithdownload = true.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.tomlonly. - Delete a folder →
remove_dir_allof 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,/capturesdisappear from the root; one/crabidychild appears (titlecrabidy). Inside it,w-saves (links) andW-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
/crabidyprovider computes a node's ownis_capturedwhen it lists it (it reads every track toml anyway → allStore?). 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 byw/W; nested folders under aW-save are captured by construction. Exact recursion depth is an implementation detail (see plan) — the invariant is "captured = fully local."
- A track row is captured per
- 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
/crabidytomls for livestorenames 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
sha2rejected for speed; hashing whole tracks is on the capture hot path.