10 KiB
Filesystem provider
Context and problem statement
crabidy currently has exactly one media provider (Tidal, crate tidaldy)
behind the ProviderClient trait and the ProviderOrchestrator that routes
by path prefix. The user wants a second provider that walks a local
directory tree and treats files with a well-known extension as serialized
track nodes: small metadata files that describe a track and point at the
thing that actually plays. The playable reference can be
- a local audio file (mp3/flac/… somewhere on disk),
- a web URL (a stream, a radio station, a direct http(s) link), or
- a crabidy-internal link (a track path owned by another provider,
e.g.
/tidal/artists/3634161/536243361).
The request's open question — "new datastructure or our existing node?" — is decided below (D1: existing node on the wire, a new on-disk schema for the file).
Assumptions (confirmed against the code)
audio-playeralready plays both cases we need natively (player_engine.rs): a source string that parses as anhttp(s)URL is streamed viastream-download; anything else is opened as a local file path. No player changes are required. (file://URLs would be rejected — the provider must return plain paths, not file URLs.)Track.pathis the routing key for playback: the queue stores wholeTrackmessages, andGetTrackUrls/get_metadata_for_trackroute by the path's first segment inProviderOrchestrator. Nothing in the server assumes a track's path belongs to the provider whose node listed it.- The default
ProviderClient::resolve_tracks_intowalk (one chunk per track-bearing node, pre-order) is fast enough for local disk I/O; the page-streaming override exists for slow remote APIs. - The TUI needs no changes:
/fsappears as one more child of the synthetic root, directories are nodes, track files are tracks.
Decisions
D1 — Reuse Track/LibraryNode; the only new schema is on disk
Options considered:
- (a) New proto message (e.g.
TrackRefwith aoneof playable) carried through queue, RPCs, and TUI. - (b) Reuse the existing
Track/LibraryNodemessages unchanged; the "reference to a playable thing" lives only inside the fs provider's on-disk file and is resolved to ordinary crabidy semantics at the provider boundary.
Decision: (b). A new wire type would ripple through the queue, every
RPC, and both clients for zero client-visible benefit — the queue and TUI
only ever need metadata + a playable path, which Track already is. The
new datastructure is purely the serialized track-file schema (D3),
private to the fs provider crate.
D2 — Internal links resolve by path rewriting at listing time
Options considered:
- (a) Keep
Track.path = /fs/...for link tracks and add an indirection mechanism at play time (orchestrator re-dispatchesget_urls_for_trackwhen the fs provider reports a redirect). - (b) When the fs provider builds a
Trackfrom a link file, it setsTrack.pathto the link target (e.g./tidal/...). The file's own metadata still fills artist/title/album. From then on the track is a tidal track as far as the queue and playback are concerned; the orchestrator's existing prefix routing does the rest.
Decision: (b). Zero new mechanisms: get_urls_for_track and metadata
refresh route to the owning provider automatically, and a dead target
degrades exactly like any other dead tidal track (playback warn + skip).
Consequences, accepted deliberately:
- The queue shows the metadata written in the file (authoritative by the user's own description), not the target's live metadata.
get_urls_for_trackon an fs path whose playable is a link cannot occur through normal flow (the path was rewritten before it could be queued); if it happens anyway it isMalformedPathwith a warning, not a chain resolution. Link chains are structurally impossible — see D3's "no links into/fs" rule.
D3 — On-disk schema: TOML, extension .track.toml, exactly one playable
TOML per project convention. A file named <anything>.track.toml inside
the configured root is a track node; everything else (other files, hidden
entries) is ignored. Schema:
# Required.
title = "We Will Rock You"
# Optional; empty when omitted (web radio streams often have no artist).
artist = "Queen"
# Optional, seconds.
duration = 122
# Optional.
[album]
title = "News of the World"
release_date = "1977-10-28"
# Required: exactly one of `file`, `url`, `link`.
[playable]
file = "../flac/we-will-rock-you.flac"
# url = "https://example.org/stream.mp3"
# link = "/tidal/artists/3634161/536243361"
playableis parsed as a struct of threeOptions and validated to exactly one set — this gives precise error messages, unlike an untagged serde enum.file: absolute, or relative to the track file's directory (so a music folder stays relocatable). Existence is not checked at listing time (TOCTOU; the player produces a good error at play time).url: must parse ashttp/https(matching what the player accepts).link: must be an absolute crabidy path (/-prefixed) and must not point into/fs— self-links would allow chains/cycles; other providers are one hop away by construction.- A file that fails to parse or validate is skipped with a warning at listing time; it never panics and never poisons its directory (hard rule: no panic on user input).
D4 — Library mapping: one configured root, encoded segments, sorted listing
- Config
~/.config/crabidy/fsdy.toml, written back with defaults on first run liketidaly.toml. Single fieldroot(absolute path); defaultdirs::audio_dir()(~/Music). One root keeps the path scheme flat; multiple roots stay future work (they would need a/fs/<root-name>/layer). - Paths:
/fs/<seg>/<seg>/…where each segment isencode_segment(file_name)— the same escaping search terms use, so arbitrary file names (spaces,%, unicode) survive the path scheme. - Traversal safety: decoded segments are rejected if they are
./..or contain a path separator; the joined path is a pure descent from the root by construction. - Symlinks are skipped during directory listing (
file_type()without follow) — no cycles, no escaping the root. Aplayable.filetarget may be a symlink; that is the player's problem. - Listing order: directories and track files each sorted
case-insensitively by file name — deterministic queueing order; users
order albums with
01-style file name prefixes as everywhere else. - Directories are
LibraryNodeChild { is_queable: true }; queueing one resolves its whole subtree via the defaultresolve_tracks_intowalk (one chunk per directory — local disk needs no page streaming). Empty directories are fine: they contribute nothing. - Fresh read on every navigation, no cache, no file watching — edits with a file manager appear on the next visit.
D5 — New crate fsdy, non-fatal init, orchestrator routing
- New workspace crate
fsdy(naming symmetry withtidaldy),PROVIDER_ROOT = "/fs", implementingProviderClient. ProviderOrchestratorgainsfs_client: Option<Arc<fsdy::Client>>and routes/fsprefixes in every trait method;get_lib_rootadds the/fschild only when the client exists. Init failure is non-fatal (warn + run without/fs): unlike Tidal, a broken local config must not take the whole server down, and existing installations have nofsdy.tomlyet. All I/O throughtokio::fs(no blocking the runtime).
D6 — Out of scope (explicitly)
create/rename/delete_lib_node:NotSupported. Track files are edited with normal file tools; a TUI editor for them is future work.- Reading audio-file tags (ID3 etc.) to synthesize track nodes for plain
.mp3files sitting in the tree: future work — this feature is about the serialized-node format. - Multiple roots, file watching, link chains: rejected above.
Structure
direction: right
disk: Local disk {
shape: cylinder
tree: "root dir: dirs, *.track.toml"
}
server: crabidy-server {
playback: Playback loop
orch: ProviderOrchestrator {
route: "route by first path segment"
}
}
fsdy: fsdy::Client {
parse: "parse + validate .track.toml"
map: "path <-> root-relative file (encoded segments)"
}
tidaldy: tidaldy::Client
player: audio-player {
url: "http(s) -> stream-download"
file: "other -> File::open"
}
server.playback -> server.orch: "GetTrackUrls(track.path)"
server.orch -> fsdy: "/fs/..."
server.orch -> tidaldy: "/tidal/..."
fsdy -> disk.tree: tokio::fs
server.playback -> player: "play(url | file path)"
Key flow: queue a directory containing all three playable kinds
shape: sequence_diagram
tui: TUI
pb: Playback loop
orch: Orchestrator
fs: fsdy
tidal: tidaldy
tui -> pb: "ReplaceQueue([/fs/mix])"
pb -> orch: ResolveTracks("/fs/mix", chunk_tx)
orch -> fs: resolve_tracks_into (spawned)
fs -> fs: "list dir, parse 3 track files"
fs -> pb: "chunk of 3 Tracks (paths below)" {style.bold: true}
pb -> orch: "GetTrackUrls(/fs/mix/a.track.toml)"
orch -> fs: get_urls_for_track
fs -> pb: "[/home/u/Music/a.flac]"
pb -> orch: "GetTrackUrls(/tidal/...) # link track, rewritten path"
orch -> tidal: get_urls_for_track
tidal -> pb: "[https://tidal-cdn/...]"
(The second track's Track.path stays /fs/... — its playable is a URL,
returned by fsdy::get_urls_for_track. Only link files rewrite the
path.)
Risks and open questions
- Malicious/odd trees: deep nesting is bounded only by the walk's worklist (memory-cheap); huge directories list in one node — accepted for local disk. Traversal and symlink escapes are closed by D4.
- Dangling references: dead
file/url/linktargets surface at play time as the existing "failed to open / no provider owns" warnings; the queue keeps going. No preflight validation by design. - Metadata drift on link tracks (file says X, target now titled Y): accepted; the file is the user's curated metadata.
- Open (future): tag-reading for bare audio files; multiple roots; a
%-style creator that writes a.track.tomlfrom inside the TUI.