9.9 KiB
Captures (downloaded subtrees)
Partially superseded by
incremental-captures.md: download captures are now incremental (no tmp-and-swap, re-capturing a name resumes it), uncapturable tracks are recorded as skipped tomls instead of being omitted, and the capture RPC streams progress. Bookmarks keep the tmp-and-swap described here.
Context and problem statement
Bookmarks (w) mirror a library subtree as link files — replaying a
bookmark still needs the original provider. The user wants W on a library
node to do the same capture into a separate local provider called
captures, except each track's audio is downloaded next to its
.cbd-track.toml, and the toml points at that file. Playback of a capture
then needs no provider round trip at all — it is a fully local copy.
A library node decides whether it allows W; Tidal implements it.
Assumptions (confirmed against the code)
PlayableSpec.filealready supports relative paths, resolved against the track file's directory atget_urls_for_tracktime (fs-provider D3). A toml next to its audio file can sayfile = "0001 Song.flac"and the whole capture folder stays relocatable (tmp-and-swap, rename, backup).fsdy::list_dironly surfaces directories and*.cbd-track.tomlfiles; downloaded audio siblings are invisible to the library listing.- The bookmark walk (
bookmark_store::write_capture) is an iterative pre-order worklist whose only per-track action is "serialize and write one file" — exactly the seam where a download variant plugs in. reqwest(rustls,stream) is already a workspace dependency; tidal stream URLs come fromget_urls_for_trackon the orchestrator, so the download needs no new provider methods.- Uppercase bindings (
K,J) already exist in the TUI bindings table;WinScope::Libraryis free. LibraryNode/LibraryNodeChildalready model per-node capabilities (is_queable,is_creatable, …) — the "does this node allowW" decision extends that pattern.
Decisions
D1 — Fourth fsdy instance at /captures
<config>/crabidy/captures/ is mounted read-only as /captures with an
editable top level (no reserved names), exactly like /bookmarks. Init is
non-fatal: an unopenable store disables W and the mount, never the
server. Loading a capture is browsing /captures and queueing a folder —
zero new replay mechanisms.
D2 — One shared walk, two track sinks
Options considered:
- (a) Copy
bookmark_store.rsand swap the per-track write. - (b) Extract the walk into a shared
capturemodule parameterized by a track sink; bookmarks and captures become thin stores over it.
Decision: (b) — the walk (worklist, caps, temp-and-swap, all-or-nothing
cleanup, BadSource mapping) is behavior we already tested once and must
not fork. crabidy-server/src/capture.rs owns CaptureError, the caps,
and write_tree(client, source, tmp, caps, sink); the sink is an enum
(no async-trait indirection):
Sink::Link— today's bookmark behavior, byte-identical (TrackFile::from_track, link playable).Sink::Download(Downloader)— captures (D3).
BookmarkStore keeps its API; CaptureStore (in capture_store.rs) is
its sibling over the captures directory.
D3 — Download sink: audio next to the toml, toml points at it
Per track, in listing order:
get_urls_for_trackthrough the orchestrator (any provider that yields URLs works; Tidal is the target). First URL wins.- HTTP GET via one shared
reqwestclient — connect timeout, one total per-track deadline covering the whole body, no retries (a capture is re-runnable and overwrite = refresh; a retry policy can come later). The body is streamed toNNNN <title>.<ext>(sharedordered_namesanitizer, same 4-digit prefix as the toml so the pair sorts together). - The extension comes from the response
Content-Type(audio/flac→flac,audio/mp4/audio/m4a→m4a,audio/mpeg→mp3,audio/ogg→ogg,audio/wav→wav), falling back to the URL path's extension, thenbin(the player probes by content; the extension is a hint). - The toml is written after the download succeeds, with
playable.file = "<audio file name>"(relative, newTrackFile::from_track_with_file), keeping metadata identical to a bookmark entry.
Caps: MAX_CAPTURE_DIRS stays 1 000; downloads get their own
MAX_DOWNLOAD_TRACKS = 500 and a total byte budget
MAX_DOWNLOAD_BYTES = 4 GiB counted while streaming — a runaway artist
capture must not fill the disk. Downloads run sequentially (gentle on the
provider, trivially bounded memory); the whole capture already runs on a
spawned task, so the orchestrator keeps serving.
All-or-nothing is kept for real failures: any failed download (bad
status, transport error, timeout) aborts the capture and removes the temp
folder. The one softening: a track whose source cannot be captured at
all — its stream fails to resolve, or resolves to a non-http(s) target
(a local file playable) — is skipped with a warning instead of
aborting. Queue and bookmark captures mix providers (D4), and one local
/fs entry must not kill the downloadable rest; the skipped track simply
has no pair in the capture.
D4 — Nodes opt in via is_downloadable
New proto fields LibraryNode.is_downloadable = 8 and
LibraryNodeChild.is_downloadable = 7 (additive). Tidal sets the flag
centrally at the end of get_lib_node: a node is downloadable when it is
queueable or lists tracks (the "or lists tracks" covers search-term
result nodes, which are not queueable as a whole but whose track results
are downloadable); children mirror is_queable. The /queues and
/bookmarks instances opt in wholesale
(fsdy::Client::with_downloadable_nodes): their entries are links into
downloadable providers, so W on a saved queue or bookmark downloads
its resolvable tracks and skips the rest (D3). /fs and /captures
stay false — capturing a capture is pointless, and local trees have
nothing to download.
Tracks carry no flag: a listed track inherits its containing node's
is_downloadable (TUI) — a Tidal album's tracks are downloadable because
the album is. The server enforces at the capture root: a directory
source must report is_downloadable, a track source's parent node must
(CaptureError::Unsupported otherwise). Nested nodes inside the walk are
not re-checked — the pressed node's decision governs its subtree.
D5 — Wire: the existing rpc gains a download flag
CaptureLibraryNodeRequest gets bool download = 3 (additive; old
clients keep bookmarking). ProviderCommand::CaptureLibraryNode carries
it and the handler picks the store. Error mapping extends the bookmark
contract: InvalidName/BadSource → invalid_argument,
TooLarge/Disabled/Unsupported → failed_precondition, download and
disk failures → internal.
D6 — TUI: W on the library pane
Action::LibraryDownloadNode bound to W in Scope::Library ("Download
selection as capture"). Gate: the bare selection must be queueable and
downloadable (selected_downloadable(); child flag for nodes, the current
node's flag for tracks; marks ignored like w). The existing input
overlay opens with InputPurpose::Capture { path, download: true }
(label capture), prefilled with the selection title. Submit sends
MessageFromUi::CaptureNode { path, name, download } → the rpc. Failures
are logged, never fatal to the poll loop.
D7 — Out of scope (explicitly)
- Retry/resume of failed or partial downloads (re-run the capture).
- Quality/codec selection, transcoding, tagging the audio files.
- Progress display in the TUI while a capture downloads.
- Deduplicating audio across captures, or refreshing links in existing bookmarks into downloads.
- DRM circumvention: the download uses exactly the stream URLs the provider already serves for playback.
Structure
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
cap: "capture.rs\nshared walk + caps + swap" {
link: "Sink::Link"
dl: "Sink::Download\n(reqwest, timeouts, byte budget)"
}
bs: BookmarkStore
cs: CaptureStore
}
tidal: "tidaldy\n(is_downloadable = is_queable)"
disk: "config/crabidy" {
shape: cylinder
b: "bookmarks/<name>/ (link tomls)"
c: "captures/<name>/ (audio + file tomls)"
}
cfs: "fsdy /captures\n(editable top level)"
server.orch -> server.bs: "CaptureLibraryNode\ndownload=false"
server.orch -> server.cs: "CaptureLibraryNode\ndownload=true"
server.bs -> server.cap.link
server.cs -> server.cap.dl
server.cap.dl -> tidal: "get_urls_for_track\n+ HTTP GET stream"
server.bs -> disk.b
server.cs -> disk.c
cfs -> disk.c: "list + parse (read only)"
server.orch -> cfs: "/captures/..."
Key flow: W on a Tidal album
shape: sequence_diagram
tui: TUI
rpc: gRPC
orch: Orchestrator
cs: CaptureStore
tidal: Tidal
tui -> rpc: "CaptureLibraryNode(path, name, download=true)"
rpc -> orch: "ProviderCommand (spawned)"
orch -> cs: "capture(name)"
cs -> orch: "get_lib_node: root allows download?"
cs -> tidal: "per track: get_urls_for_track"
cs -> tidal: "HTTP GET (deadline, byte budget)"
cs -> cs: "audio + toml pair\n(toml after audio, file = relative)"
cs -> rpc: "tmp-and-swap captures/<name>/"
rpc -> tui: OK
Risks and open questions
- Disk usage: 500 tracks of FLAC can be tens of GiB; the byte budget
caps one capture, not the folder's total. Accepted — the user manages
captures/like any local music folder (and can delete viad). - Stream URL churn: Tidal URLs are short-lived; the download happens immediately after fetching each URL, so expiry only matters for very slow transfers, which the per-track deadline already bounds.
- Licensing: captures are personal-use copies of streams the account can already play; nothing here bypasses provider protection.
- Open (future): a progress event stream for long captures; retry with classification + jitter; per-provider download quality knobs.