Add a filesystem provider for serialized track nodes
A new fsdy crate exposes a configured root directory as /fs: *.track.toml files are track nodes carrying metadata plus exactly one playable reference — a local audio file, an http(s) URL, or a crabidy-internal link. Link tracks rewrite Track.path to the target at listing time, so playback routes through the existing prefix routing; links into /fs are rejected, making chains impossible. Paths are percent-encoded segments validated in one place (no root escape), the orchestrator wires the provider optionally (a broken local config only costs the /fs subtree), and the default chunked resolve walk provides progressive queueing for free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ccab43a133
commit
4dd5f01217
|
|
@ -745,6 +745,7 @@ dependencies = [
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flume",
|
"flume",
|
||||||
|
"fsdy",
|
||||||
"futures",
|
"futures",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
|
|
@ -1223,6 +1224,23 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fsdy"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"crabidy-core",
|
||||||
|
"dirs",
|
||||||
|
"flume",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ members = [
|
||||||
"cbd-tui",
|
"cbd-tui",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
|
"fsdy",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ stream-download = { version = "0.24", default-features = false, features = [
|
||||||
"reqwest-rustls",
|
"reqwest-rustls",
|
||||||
"temp-storage",
|
"temp-storage",
|
||||||
] }
|
] }
|
||||||
|
tempfile = "3"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
|
@ -64,4 +66,5 @@ url = "2"
|
||||||
# Local crates
|
# Local crates
|
||||||
audio-player = { path = "audio-player" }
|
audio-player = { path = "audio-player" }
|
||||||
crabidy-core = { path = "crabidy-core" }
|
crabidy-core = { path = "crabidy-core" }
|
||||||
|
fsdy = { path = "fsdy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
1. a **local audio file** (mp3/flac/… somewhere on disk),
|
||||||
|
2. a **web URL** (a stream, a radio station, a direct http(s) link), or
|
||||||
|
3. 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-player` already plays both cases we need natively
|
||||||
|
(`player_engine.rs`): a source string that parses as an `http(s)` URL is
|
||||||
|
streamed via `stream-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.path` is the routing key for playback: the queue stores whole
|
||||||
|
`Track` messages, and `GetTrackUrls`/`get_metadata_for_track` route by
|
||||||
|
the path's first segment in `ProviderOrchestrator`. Nothing in the
|
||||||
|
server assumes a track's path belongs to the provider whose node listed
|
||||||
|
it.
|
||||||
|
- The default `ProviderClient::resolve_tracks_into` walk (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**: `/fs` appears 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. `TrackRef` with a `oneof playable`) carried
|
||||||
|
through queue, RPCs, and TUI.
|
||||||
|
- *(b)* Reuse the existing `Track`/`LibraryNode` messages 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-dispatches `get_urls_for_track`
|
||||||
|
when the fs provider reports a redirect).
|
||||||
|
- *(b)* When the fs provider builds a `Track` from a link file, it sets
|
||||||
|
`Track.path` to 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_track` on 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 is `MalformedPath` with 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:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `playable` is parsed as a struct of three `Option`s 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 as `http`/`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 like `tidaly.toml`. Single field `root` (absolute path);
|
||||||
|
default `dirs::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 is
|
||||||
|
`encode_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. A `playable.file` target 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 **default** `resolve_tracks_into`
|
||||||
|
walk (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 with `tidaldy`),
|
||||||
|
`PROVIDER_ROOT = "/fs"`, implementing `ProviderClient`.
|
||||||
|
- `ProviderOrchestrator` gains `fs_client: Option<Arc<fsdy::Client>>` and
|
||||||
|
routes `/fs` prefixes in every trait method; `get_lib_root` adds the
|
||||||
|
`/fs` child 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 no
|
||||||
|
`fsdy.toml` yet. All I/O through `tokio::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
|
||||||
|
`.mp3` files sitting in the tree: future work — this feature is about
|
||||||
|
the serialized-node format.
|
||||||
|
- Multiple roots, file watching, link chains: rejected above.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
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
|
||||||
|
|
||||||
|
```d2
|
||||||
|
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`/`link` targets 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.toml` from inside the TUI.
|
||||||
|
|
@ -14,6 +14,7 @@ audio-player.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
fsdy.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
tidaldy.workspace = true
|
tidaldy.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,25 @@ pub struct ProviderOrchestrator {
|
||||||
pub provider_tx: flume::Sender<ProviderMessage>,
|
pub provider_tx: flume::Sender<ProviderMessage>,
|
||||||
provider_rx: flume::Receiver<ProviderMessage>,
|
provider_rx: flume::Receiver<ProviderMessage>,
|
||||||
tidal_client: Arc<tidaldy::Client>,
|
tidal_client: Arc<tidaldy::Client>,
|
||||||
|
/// `None` when the filesystem provider failed to initialize — the
|
||||||
|
/// server runs without `/fs` instead of dying (architecture D5).
|
||||||
|
fs_client: Option<Arc<fsdy::Client>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the filesystem provider.
|
||||||
|
fn fs_owns(path: &str) -> bool {
|
||||||
|
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderOrchestrator {
|
impl ProviderOrchestrator {
|
||||||
|
/// The fs client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// provider is disabled — a `/fs` path then has no owner.
|
||||||
|
fn fs_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.fs_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("filesystem provider is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Behind an Arc so long-running resolves can be spawned onto
|
// Behind an Arc so long-running resolves can be spawned onto
|
||||||
|
|
@ -119,11 +135,29 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
||||||
error!("failed to write tidal config file: {err}");
|
error!("failed to write tidal config file: {err}");
|
||||||
};
|
};
|
||||||
|
// The filesystem provider is optional: a broken local config only
|
||||||
|
// costs the `/fs` subtree, never the server.
|
||||||
|
let fs_config_file = config_dir.join("fsdy.toml");
|
||||||
|
debug!(config_file = %fs_config_file.display(), "loading fs config");
|
||||||
|
let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
|
||||||
|
let fs_client = match fsdy::Client::init(&raw_fs_settings).await {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await {
|
||||||
|
error!("failed to write fsdy config file: {err}");
|
||||||
|
}
|
||||||
|
Some(Arc::new(client))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("filesystem provider disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
let (provider_tx, provider_rx) = flume::bounded(100);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
provider_rx,
|
provider_rx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
tidal_client,
|
tidal_client,
|
||||||
|
fs_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +170,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == "/tidal" || path.starts_with("/tidal/") {
|
if path == "/tidal" || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.is_track_path(path);
|
return self.tidal_client.is_track_path(path);
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self
|
||||||
|
.fs_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|fs| fs.is_track_path(path));
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +184,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if track_path.starts_with("/tidal/") {
|
if track_path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_urls_for_track(track_path).await;
|
return self.tidal_client.get_urls_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(track_path) {
|
||||||
|
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +196,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if track_path.starts_with("/tidal/") {
|
if track_path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_metadata_for_track(track_path).await;
|
return self.tidal_client.get_metadata_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(track_path) {
|
||||||
|
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +208,11 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
let child =
|
let child =
|
||||||
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
|
if self.fs_client.is_some() {
|
||||||
|
let child =
|
||||||
|
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
root_node
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,6 +225,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_lib_node(path).await;
|
return self.tidal_client.get_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self.fs_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +243,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.create_lib_node(parent_path, title).await;
|
return self.tidal_client.create_lib_node(parent_path, title).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.fs_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(parent_path, "no provider supports creating nodes here");
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +264,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.rename_lib_node(path, new_title).await;
|
return self.tidal_client.rename_lib_node(path, new_title).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports renaming this node");
|
warn!(path, "no provider supports renaming this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +282,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.resolve_tracks_into(path, chunk_tx).await;
|
return self.tidal_client.resolve_tracks_into(path, chunk_tx).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self
|
||||||
|
.fs_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +299,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.delete_lib_node(path).await;
|
return self.tidal_client.delete_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self.fs_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports deleting this node");
|
warn!(path, "no provider supports deleting this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "fsdy"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait.workspace = true
|
||||||
|
crabidy-core.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
|
toml.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
flume.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
@ -0,0 +1,792 @@
|
||||||
|
//! Filesystem media provider.
|
||||||
|
//!
|
||||||
|
//! Walks a single configured root directory and treats every file ending in
|
||||||
|
//! [`TRACK_FILE_SUFFIX`] as a *serialized track node*: a TOML file carrying
|
||||||
|
//! the track's metadata plus a reference to the playable thing — a local
|
||||||
|
//! audio file, a web URL, or a crabidy-internal link into another provider
|
||||||
|
//! (see `architecture/fs-provider.md`).
|
||||||
|
//!
|
||||||
|
//! Library paths mirror the directory tree under [`PROVIDER_ROOT`]; every
|
||||||
|
//! file-name segment is percent-encoded with
|
||||||
|
//! [`crabidy_core::encode_segment`] so arbitrary names survive the path
|
||||||
|
//! scheme. Directories are queueable nodes, track files are tracks, and the
|
||||||
|
//! default [`ProviderClient::resolve_tracks_into`] walk provides chunked
|
||||||
|
//! queue resolution.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crabidy_core::{
|
||||||
|
proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track},
|
||||||
|
ProviderClient, ProviderError,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// First path segment owned by this provider.
|
||||||
|
pub const PROVIDER_ROOT: &str = "/fs";
|
||||||
|
|
||||||
|
/// Files with this suffix are serialized track nodes; everything else in
|
||||||
|
/// the tree is ignored.
|
||||||
|
pub const TRACK_FILE_SUFFIX: &str = ".track.toml";
|
||||||
|
|
||||||
|
/// Provider settings, persisted as `fsdy.toml` next to the other crabidy
|
||||||
|
/// config files.
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Absolute path of the directory to expose under `/fs`. When unset,
|
||||||
|
/// the platform music directory (`dirs::audio_dir()`) is used.
|
||||||
|
pub root: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from parsing or validating a serialized track file.
|
||||||
|
///
|
||||||
|
/// These never escape the crate as-is: at the [`ProviderClient`] boundary
|
||||||
|
/// they map to [`ProviderError`] (and inside directory listings a bad file
|
||||||
|
/// is skipped with a warning instead of failing the node).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TrackFileError {
|
||||||
|
#[error("not valid TOML: {0}")]
|
||||||
|
Toml(#[from] toml::de::Error),
|
||||||
|
#[error("[playable] must set exactly one of `file`, `url`, `link`")]
|
||||||
|
PlayableCardinality,
|
||||||
|
/// Carries only the offending *scheme* — a private stream URL may
|
||||||
|
/// embed a token, and this error's message ends up in logs.
|
||||||
|
#[error("playable url is not http(s), scheme: {0}")]
|
||||||
|
UrlScheme(String),
|
||||||
|
#[error("playable link must be an absolute crabidy path: {0}")]
|
||||||
|
LinkNotAbsolute(String),
|
||||||
|
#[error("playable link must not point into {PROVIDER_ROOT}: {0}")]
|
||||||
|
LinkIntoFs(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The on-disk schema of a `*.track.toml` file. See
|
||||||
|
/// `architecture/fs-provider.md` (D3) for the format documentation.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TrackFile {
|
||||||
|
/// Track title. Required.
|
||||||
|
pub title: String,
|
||||||
|
/// Optional; empty when omitted (e.g. web radio streams).
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: String,
|
||||||
|
/// Optional duration in seconds.
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
/// Optional album metadata.
|
||||||
|
pub album: Option<AlbumMeta>,
|
||||||
|
/// The playable reference; exactly one of its fields must be set.
|
||||||
|
pub playable: PlayableSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional `[album]` table of a track file.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumMeta {
|
||||||
|
pub title: String,
|
||||||
|
pub release_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `[playable]` table: three optional fields so cardinality errors are
|
||||||
|
/// precise. Validated into a [`Playable`] before use.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlayableSpec {
|
||||||
|
/// Local audio file; absolute, or relative to the track file's
|
||||||
|
/// directory.
|
||||||
|
pub file: Option<PathBuf>,
|
||||||
|
/// http(s) URL streamed by the player.
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Absolute crabidy track path owned by another provider
|
||||||
|
/// (e.g. `/tidal/...`). Links into `/fs` are rejected (no chains).
|
||||||
|
pub link: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated playable reference.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Playable {
|
||||||
|
File(PathBuf),
|
||||||
|
Url(String),
|
||||||
|
Link(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackFile {
|
||||||
|
/// Parses and validates a serialized track file.
|
||||||
|
///
|
||||||
|
/// Never panics on malformed input; every defect is a typed
|
||||||
|
/// [`TrackFileError`].
|
||||||
|
pub fn parse(text: &str) -> Result<Self, TrackFileError> {
|
||||||
|
let file: TrackFile = toml::from_str(text)?;
|
||||||
|
// Validate eagerly so directory listings can skip a bad file on
|
||||||
|
// parse alone.
|
||||||
|
file.playable()?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validated playable reference.
|
||||||
|
pub fn playable(&self) -> Result<Playable, TrackFileError> {
|
||||||
|
match (&self.playable.file, &self.playable.url, &self.playable.link) {
|
||||||
|
(Some(file), None, None) => Ok(Playable::File(file.clone())),
|
||||||
|
(None, Some(url), None) => {
|
||||||
|
let scheme = url::Url::parse(url)
|
||||||
|
.map(|u| u.scheme().to_string())
|
||||||
|
.unwrap_or_else(|_| "<not a url>".to_string());
|
||||||
|
if !matches!(scheme.as_str(), "http" | "https") {
|
||||||
|
// Only the scheme: the URL itself may embed a token.
|
||||||
|
return Err(TrackFileError::UrlScheme(scheme));
|
||||||
|
}
|
||||||
|
Ok(Playable::Url(url.clone()))
|
||||||
|
}
|
||||||
|
(None, None, Some(link)) => {
|
||||||
|
if !link.starts_with('/') {
|
||||||
|
return Err(TrackFileError::LinkNotAbsolute(link.clone()));
|
||||||
|
}
|
||||||
|
if link == PROVIDER_ROOT || link.starts_with("/fs/") {
|
||||||
|
return Err(TrackFileError::LinkIntoFs(link.clone()));
|
||||||
|
}
|
||||||
|
Ok(Playable::Link(link.clone()))
|
||||||
|
}
|
||||||
|
_ => Err(TrackFileError::PlayableCardinality),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the wire [`Track`] for this file at library path `lib_path`.
|
||||||
|
///
|
||||||
|
/// For a [`Playable::Link`] the returned track's `path` is the **link
|
||||||
|
/// target**, not `lib_path` — from then on the track routes to the
|
||||||
|
/// provider that owns the target (architecture D2). File and URL
|
||||||
|
/// playables keep `lib_path`.
|
||||||
|
pub fn to_track(&self, lib_path: &str) -> Track {
|
||||||
|
let path = match self.playable() {
|
||||||
|
Ok(Playable::Link(target)) => target,
|
||||||
|
_ => lib_path.to_string(),
|
||||||
|
};
|
||||||
|
Track {
|
||||||
|
path,
|
||||||
|
artist: self.artist.clone(),
|
||||||
|
title: self.title.clone(),
|
||||||
|
duration: self.duration,
|
||||||
|
album: self.album.as_ref().map(|a| Album {
|
||||||
|
title: a.title.clone(),
|
||||||
|
release_date: a.release_date.clone(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filesystem provider.
|
||||||
|
///
|
||||||
|
/// All I/O is `tokio::fs`; nothing is cached — every node visit reads the
|
||||||
|
/// directory fresh, so edits made with normal file tools appear on the next
|
||||||
|
/// navigation. The client never panics on the contents of the tree.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Client {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Maps a `/fs/...` library path to the on-disk path it addresses.
|
||||||
|
///
|
||||||
|
/// Segments are decoded with [`crabidy_core::decode_segment`] and each
|
||||||
|
/// must be a plain file name: decoded segments that are empty, `.`,
|
||||||
|
/// `..`, or contain a path separator are rejected with
|
||||||
|
/// [`ProviderError::MalformedPath`] — the result is always a pure
|
||||||
|
/// descent from the root, so client-supplied paths cannot escape it.
|
||||||
|
fn disk_path(&self, lib_path: &str) -> Result<PathBuf, ProviderError> {
|
||||||
|
let rest = if lib_path == PROVIDER_ROOT {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
lib_path
|
||||||
|
.strip_prefix("/fs/")
|
||||||
|
.ok_or(ProviderError::MalformedPath)?
|
||||||
|
};
|
||||||
|
let mut disk = self.root.clone();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Ok(disk);
|
||||||
|
}
|
||||||
|
for segment in rest.split('/') {
|
||||||
|
if segment.is_empty() {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
let name = crabidy_core::decode_segment(segment);
|
||||||
|
if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
disk.push(&name);
|
||||||
|
}
|
||||||
|
Ok(disk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists a directory as a library node: subdirectories become queueable
|
||||||
|
/// child nodes, `*.track.toml` files become tracks, each list sorted
|
||||||
|
/// case-insensitively by file name.
|
||||||
|
///
|
||||||
|
/// Symlinks, hidden entries (dot-prefixed), non-UTF-8 names, and track
|
||||||
|
/// files that fail to parse are skipped with a warning — one bad file
|
||||||
|
/// never poisons its directory. A missing or unreadable directory is
|
||||||
|
/// [`ProviderError::MalformedPath`].
|
||||||
|
async fn list_dir(
|
||||||
|
&self,
|
||||||
|
lib_path: &str,
|
||||||
|
disk_path: &Path,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let mut read_dir = tokio::fs::read_dir(disk_path).await.map_err(|err| {
|
||||||
|
warn!(path = lib_path, "cannot list directory: {err}");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})?;
|
||||||
|
let mut dir_names: Vec<String> = Vec::new();
|
||||||
|
let mut track_names: Vec<String> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let entry = match read_dir.next_entry().await {
|
||||||
|
Ok(Some(entry)) => entry,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(path = lib_path, "error while listing directory: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Ok(file_type) = entry.file_type().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Symlinks are skipped by design: no cycles, no escaping the
|
||||||
|
// root (architecture D4).
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||||
|
warn!(path = lib_path, "skipping entry with non-UTF-8 name");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if file_type.is_dir() {
|
||||||
|
dir_names.push(name);
|
||||||
|
} else if name.ends_with(TRACK_FILE_SUFFIX) {
|
||||||
|
track_names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir_names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
track_names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for name in track_names {
|
||||||
|
let child_lib = crabidy_core::join_path(lib_path, &crabidy_core::encode_segment(&name));
|
||||||
|
// A bad file is skipped (read_track_file warned) and never
|
||||||
|
// poisons its siblings.
|
||||||
|
if let Ok(file) = self.read_track_file(&disk_path.join(&name)).await {
|
||||||
|
tracks.push(file.to_track(&child_lib));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let children = dir_names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let child_lib =
|
||||||
|
crabidy_core::join_path(lib_path, &crabidy_core::encode_segment(&name));
|
||||||
|
LibraryNodeChild::new(child_lib, name, true)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = if lib_path == PROVIDER_ROOT {
|
||||||
|
"fs".to_string()
|
||||||
|
} else {
|
||||||
|
crabidy_core::path_segments(lib_path)
|
||||||
|
.last()
|
||||||
|
.map(|s| crabidy_core::decode_segment(s))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
Ok(LibraryNode {
|
||||||
|
path: lib_path.to_string(),
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
parent: crabidy_core::parent_path(lib_path).map(String::from),
|
||||||
|
tracks,
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and parses one serialized track file. Failures are warned
|
||||||
|
/// here (with the file's path, never its contents) so every caller
|
||||||
|
/// can simply skip.
|
||||||
|
async fn read_track_file(&self, disk_path: &Path) -> Result<TrackFile, ProviderError> {
|
||||||
|
let text = tokio::fs::read_to_string(disk_path).await.map_err(|err| {
|
||||||
|
warn!(file = %disk_path.display(), "cannot read track file: {err}");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})?;
|
||||||
|
TrackFile::parse(&text).map_err(|err| {
|
||||||
|
warn!(file = %disk_path.display(), "skipping invalid track file: {err}");
|
||||||
|
ProviderError::InvalidInput
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderClient for Client {
|
||||||
|
/// Initializes from the raw `fsdy.toml` contents (may be empty).
|
||||||
|
///
|
||||||
|
/// The root defaults to `dirs::audio_dir()`; when neither is available
|
||||||
|
/// this is [`ProviderError::Config`]. A root that does not exist (yet)
|
||||||
|
/// is accepted — listing it just fails until it appears.
|
||||||
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
||||||
|
let settings: Settings = toml::from_str(raw_toml_settings)
|
||||||
|
.map_err(|err| ProviderError::Config(format!("invalid fsdy settings: {err}")))?;
|
||||||
|
let root = settings.root.or_else(dirs::audio_dir).ok_or_else(|| {
|
||||||
|
ProviderError::Config(
|
||||||
|
"no `root` configured and no platform music directory".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !root.is_absolute() {
|
||||||
|
return Err(ProviderError::Config(format!(
|
||||||
|
"`root` must be an absolute path: {}",
|
||||||
|
root.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the effective settings back for the config write-back.
|
||||||
|
fn settings(&self) -> String {
|
||||||
|
let settings = Settings {
|
||||||
|
root: Some(self.root.clone()),
|
||||||
|
};
|
||||||
|
toml::to_string_pretty(&settings).unwrap_or_else(|err| {
|
||||||
|
warn!("cannot serialize fsdy settings: {err}");
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/fs/...` paths ending in [`TRACK_FILE_SUFFIX`] address tracks.
|
||||||
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
|
path.starts_with("/fs/") && path.ends_with(TRACK_FILE_SUFFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the playable reference of a track file.
|
||||||
|
///
|
||||||
|
/// [`Playable::File`] yields the absolute file path (relative values
|
||||||
|
/// joined onto the track file's directory) — the player opens non-URL
|
||||||
|
/// sources as local files. [`Playable::Url`] yields the URL as-is.
|
||||||
|
/// [`Playable::Link`] cannot be reached through normal flow (the
|
||||||
|
/// track's path was rewritten at listing time, see [`TrackFile::to_track`])
|
||||||
|
/// and is [`ProviderError::MalformedPath`] with a warning.
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
|
if !self.is_track_path(track_path) {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
let disk = self.disk_path(track_path)?;
|
||||||
|
let file = self.read_track_file(&disk).await?;
|
||||||
|
// Parsing validated the playable; a failure here is a programmer
|
||||||
|
// error surfaced as a typed error, not a panic.
|
||||||
|
let playable = file.playable().map_err(|_| ProviderError::InternalError)?;
|
||||||
|
match playable {
|
||||||
|
Playable::File(target) => {
|
||||||
|
let absolute = if target.is_absolute() {
|
||||||
|
target
|
||||||
|
} else {
|
||||||
|
// Relative to the track file's directory (D3), so a
|
||||||
|
// music folder stays relocatable.
|
||||||
|
disk.parent().map(|dir| dir.join(&target)).unwrap_or(target)
|
||||||
|
};
|
||||||
|
let path = absolute
|
||||||
|
.to_str()
|
||||||
|
.ok_or(ProviderError::InternalError)?
|
||||||
|
.to_string();
|
||||||
|
Ok(vec![path])
|
||||||
|
}
|
||||||
|
Playable::Url(target) => Ok(vec![target]),
|
||||||
|
Playable::Link(target) => {
|
||||||
|
// Link tracks carry the target path from listing time on;
|
||||||
|
// reaching this arm means the caller bypassed that.
|
||||||
|
warn!(
|
||||||
|
path = track_path,
|
||||||
|
target, "link tracks resolve at their target provider"
|
||||||
|
);
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
|
if !self.is_track_path(track_path) {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
let disk = self.disk_path(track_path)?;
|
||||||
|
let file = self.read_track_file(&disk).await?;
|
||||||
|
Ok(file.to_track(track_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal `/fs` node; children are discovered via [`Self::get_lib_node`]
|
||||||
|
/// (the trait method is synchronous, directory listing is not).
|
||||||
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
|
LibraryNode {
|
||||||
|
path: PROVIDER_ROOT.to_string(),
|
||||||
|
title: "fs".to_string(),
|
||||||
|
children: Vec::new(),
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
if self.is_track_path(path) {
|
||||||
|
// Tracks are not nodes; the walk asks for metadata instead.
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
let disk = self.disk_path(path)?;
|
||||||
|
self.list_dir(path, &disk).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not supported — track files are created with normal file tools.
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
_parent_path: &str,
|
||||||
|
_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not supported — track files are renamed with normal file tools.
|
||||||
|
async fn rename_lib_node(
|
||||||
|
&self,
|
||||||
|
_path: &str,
|
||||||
|
_new_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not supported — track files are deleted with normal file tools.
|
||||||
|
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `resolve_tracks_into` deliberately keeps the default pre-order walk:
|
||||||
|
// local disk needs no page streaming, and one chunk per directory
|
||||||
|
// already gives progressive queueing.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A client rooted in a fresh temp directory.
|
||||||
|
async fn client_with_root() -> (Client, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let settings = format!("root = {:?}\n", dir.path().to_str().expect("utf8 tempdir"));
|
||||||
|
let client = Client::init(&settings).await.expect("init");
|
||||||
|
(client, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_track(dir: &Path, name: &str, contents: &str) {
|
||||||
|
fs::write(dir.join(name), contents).expect("write track file");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_track(title: &str) -> String {
|
||||||
|
format!("title = {title:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- on-disk schema ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_a_full_track_file() {
|
||||||
|
let file = TrackFile::parse(
|
||||||
|
r#"
|
||||||
|
title = "We Will Rock You"
|
||||||
|
artist = "Queen"
|
||||||
|
duration = 122
|
||||||
|
[album]
|
||||||
|
title = "News of the World"
|
||||||
|
release_date = "1977-10-28"
|
||||||
|
[playable]
|
||||||
|
file = "../flac/we-will-rock-you.flac"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("valid file");
|
||||||
|
assert_eq!(file.title, "We Will Rock You");
|
||||||
|
assert_eq!(file.artist, "Queen");
|
||||||
|
assert_eq!(file.duration, Some(122));
|
||||||
|
assert_eq!(
|
||||||
|
file.album.as_ref().map(|a| a.title.as_str()),
|
||||||
|
Some("News of the World")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file.playable().expect("playable"),
|
||||||
|
Playable::File(PathBuf::from("../flac/we-will-rock-you.flac"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_defaults_optional_metadata() {
|
||||||
|
// A stream has no artist/duration/album; only title + playable are
|
||||||
|
// required.
|
||||||
|
let file = TrackFile::parse(&url_track("Radio X")).expect("valid file");
|
||||||
|
assert_eq!(file.artist, "");
|
||||||
|
assert_eq!(file.duration, None);
|
||||||
|
assert!(file.album.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
file.playable().expect("playable"),
|
||||||
|
Playable::Url("https://example.org/s.mp3".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_wrong_playable_cardinality() {
|
||||||
|
let none = "title = \"x\"\n[playable]\n";
|
||||||
|
let both = "title = \"x\"\n[playable]\nurl = \"https://a\"\nlink = \"/tidal/t\"\n";
|
||||||
|
for bad in [none, both] {
|
||||||
|
let err = TrackFile::parse(bad)
|
||||||
|
.and_then(|f| f.playable().map(|_| f))
|
||||||
|
.expect_err("cardinality must be rejected");
|
||||||
|
assert!(matches!(err, TrackFileError::PlayableCardinality), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_invalid_playables() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"title = \"x\"\n[playable]\nurl = \"ftp://example.org/x\"\n",
|
||||||
|
"url scheme",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title = \"x\"\n[playable]\nlink = \"tidal/relative\"\n",
|
||||||
|
"relative link",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title = \"x\"\n[playable]\nlink = \"/fs/other.track.toml\"\n",
|
||||||
|
"link into /fs",
|
||||||
|
),
|
||||||
|
("title = \"x\"\nnot toml at all [", "invalid toml"),
|
||||||
|
];
|
||||||
|
for (bad, what) in cases {
|
||||||
|
assert!(
|
||||||
|
TrackFile::parse(bad)
|
||||||
|
.and_then(|f| f.playable().map(|_| ()))
|
||||||
|
.is_err(),
|
||||||
|
"{what} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_track_rewrites_the_path_only_for_links() {
|
||||||
|
let lib_path = "/fs/mix/song.track.toml";
|
||||||
|
let linked = TrackFile::parse(
|
||||||
|
"title = \"t\"\nartist = \"a\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||||
|
)
|
||||||
|
.expect("valid");
|
||||||
|
// A link track *is* the target from the queue's point of view
|
||||||
|
// (architecture D2) ...
|
||||||
|
assert_eq!(linked.to_track(lib_path).path, "/tidal/artists/1/2");
|
||||||
|
// ... while file/url playables stay fs tracks.
|
||||||
|
let local =
|
||||||
|
TrackFile::parse("title = \"t\"\n[playable]\nfile = \"x.mp3\"\n").expect("valid");
|
||||||
|
assert_eq!(local.to_track(lib_path).path, lib_path);
|
||||||
|
let stream = TrackFile::parse(&url_track("t")).expect("valid");
|
||||||
|
assert_eq!(stream.to_track(lib_path).path, lib_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- path scheme --------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track_paths_need_the_suffix_and_the_provider_prefix() {
|
||||||
|
let (client, _dir) = client_with_root().await;
|
||||||
|
assert!(client.is_track_path("/fs/mix/song.track.toml"));
|
||||||
|
assert!(!client.is_track_path("/fs/mix"));
|
||||||
|
assert!(!client.is_track_path("/tidal/song.track.toml"));
|
||||||
|
assert!(!client.is_track_path("/fs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn client_paths_cannot_escape_the_root() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
// A sibling of the root that must stay unreachable.
|
||||||
|
fs::create_dir(dir.path().join("inside")).expect("mkdir");
|
||||||
|
for evil in [
|
||||||
|
"/fs/..",
|
||||||
|
"/fs/../inside",
|
||||||
|
"/fs/inside/../..",
|
||||||
|
"/fs/a%2Fb", // decodes to "a/b"
|
||||||
|
"/fs/%2E%2E", // decodes to ".."
|
||||||
|
"/fs//inside", // empty segment
|
||||||
|
] {
|
||||||
|
let err = client.get_lib_node(evil).await.expect_err(evil);
|
||||||
|
assert_eq!(err, ProviderError::MalformedPath, "{evil}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- directory listing ---------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn listing_sorts_and_skips_foreign_hidden_and_broken_entries() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
fs::create_dir(dir.path().join("b-dir")).expect("mkdir");
|
||||||
|
fs::create_dir(dir.path().join("A-dir")).expect("mkdir");
|
||||||
|
fs::create_dir(dir.path().join(".hidden-dir")).expect("mkdir");
|
||||||
|
write_track(dir.path(), "b song.track.toml", &url_track("b"));
|
||||||
|
write_track(dir.path(), "A song.track.toml", &url_track("a"));
|
||||||
|
write_track(dir.path(), "broken.track.toml", "not [ valid");
|
||||||
|
write_track(dir.path(), ".hidden.track.toml", &url_track("h"));
|
||||||
|
fs::write(dir.path().join("cover.jpg"), b"jpg").expect("write");
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::symlink(dir.path().join("b-dir"), dir.path().join("z-link"))
|
||||||
|
.expect("symlink");
|
||||||
|
|
||||||
|
let node = client.get_lib_node("/fs").await.expect("root listing");
|
||||||
|
assert!(node.is_queable);
|
||||||
|
// Case-insensitive sort; hidden/broken/foreign/symlinked entries
|
||||||
|
// are all invisible.
|
||||||
|
let children: Vec<&str> = node.children.iter().map(|c| c.title.as_str()).collect();
|
||||||
|
assert_eq!(children, vec!["A-dir", "b-dir"]);
|
||||||
|
assert!(node.children.iter().all(|c| c.is_queable));
|
||||||
|
let tracks: Vec<&str> = node.tracks.iter().map(|t| t.title.as_str()).collect();
|
||||||
|
assert_eq!(tracks, vec!["a", "b"]);
|
||||||
|
// Track paths are encoded segments under the node's path.
|
||||||
|
assert_eq!(node.tracks[0].path, "/fs/A%20song.track.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn listing_a_missing_directory_is_an_error_not_a_panic() {
|
||||||
|
let (client, _dir) = client_with_root().await;
|
||||||
|
let err = client
|
||||||
|
.get_lib_node("/fs/nope")
|
||||||
|
.await
|
||||||
|
.expect_err("missing dir");
|
||||||
|
assert_eq!(err, ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nodes_link_back_to_their_parent() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
fs::create_dir_all(dir.path().join("a/b")).expect("mkdir");
|
||||||
|
write_track(&dir.path().join("a/b"), "t.track.toml", &url_track("t"));
|
||||||
|
let node = client.get_lib_node("/fs/a/b").await.expect("node");
|
||||||
|
assert_eq!(node.parent.as_deref(), Some("/fs/a"));
|
||||||
|
assert_eq!(node.title, "b");
|
||||||
|
let root = client.get_lib_node("/fs").await.expect("root");
|
||||||
|
assert_eq!(root.parent.as_deref(), Some(crabidy_core::ROOT_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- playable resolution -------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn urls_resolve_per_playable_kind() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
let sub = dir.path().join("mix");
|
||||||
|
fs::create_dir(&sub).expect("mkdir");
|
||||||
|
write_track(
|
||||||
|
&sub,
|
||||||
|
"rel.track.toml",
|
||||||
|
"title = \"r\"\n[playable]\nfile = \"a.flac\"\n",
|
||||||
|
);
|
||||||
|
let abs_target = dir.path().join("elsewhere.mp3");
|
||||||
|
write_track(
|
||||||
|
&sub,
|
||||||
|
"abs.track.toml",
|
||||||
|
&format!(
|
||||||
|
"title = \"a\"\n[playable]\nfile = {:?}\n",
|
||||||
|
abs_target.to_str().expect("utf8")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
write_track(&sub, "web.track.toml", &url_track("w"));
|
||||||
|
write_track(
|
||||||
|
&sub,
|
||||||
|
"linked.track.toml",
|
||||||
|
"title = \"l\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Relative files resolve against the track file's directory.
|
||||||
|
let urls = client
|
||||||
|
.get_urls_for_track("/fs/mix/rel.track.toml")
|
||||||
|
.await
|
||||||
|
.expect("relative file");
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec![sub.join("a.flac").to_str().expect("utf8").to_string()]
|
||||||
|
);
|
||||||
|
// Absolute files pass through.
|
||||||
|
let urls = client
|
||||||
|
.get_urls_for_track("/fs/mix/abs.track.toml")
|
||||||
|
.await
|
||||||
|
.expect("absolute file");
|
||||||
|
assert_eq!(urls, vec![abs_target.to_str().expect("utf8").to_string()]);
|
||||||
|
// URLs pass through.
|
||||||
|
let urls = client
|
||||||
|
.get_urls_for_track("/fs/mix/web.track.toml")
|
||||||
|
.await
|
||||||
|
.expect("url");
|
||||||
|
assert_eq!(urls, vec!["https://example.org/s.mp3".to_string()]);
|
||||||
|
// Link playables never resolve here: their tracks route to the
|
||||||
|
// target provider, so landing here means a malformed request.
|
||||||
|
let err = client
|
||||||
|
.get_urls_for_track("/fs/mix/linked.track.toml")
|
||||||
|
.await
|
||||||
|
.expect_err("link");
|
||||||
|
assert_eq!(err, ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn metadata_of_a_link_track_carries_the_target_path() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
write_track(
|
||||||
|
dir.path(),
|
||||||
|
"linked.track.toml",
|
||||||
|
"title = \"t\"\nartist = \"a\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||||
|
);
|
||||||
|
let track = client
|
||||||
|
.get_metadata_for_track("/fs/linked.track.toml")
|
||||||
|
.await
|
||||||
|
.expect("metadata");
|
||||||
|
assert_eq!(track.path, "/tidal/artists/1/2");
|
||||||
|
assert_eq!(track.title, "t");
|
||||||
|
assert_eq!(track.artist, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- queue resolution (trait default over real listings) -----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolving_a_tree_streams_chunks_in_listing_order() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
let al1 = dir.path().join("artist/album1");
|
||||||
|
let al2 = dir.path().join("artist/album2");
|
||||||
|
fs::create_dir_all(&al1).expect("mkdir");
|
||||||
|
fs::create_dir_all(&al2).expect("mkdir");
|
||||||
|
write_track(&al1, "01.track.toml", &url_track("one"));
|
||||||
|
write_track(&al1, "02.track.toml", &url_track("two"));
|
||||||
|
write_track(&al2, "01.track.toml", &url_track("three"));
|
||||||
|
|
||||||
|
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||||
|
client
|
||||||
|
.resolve_tracks_into("/fs/artist", chunk_tx)
|
||||||
|
.await
|
||||||
|
.expect("resolve");
|
||||||
|
let chunks: Vec<Vec<String>> = chunk_rx
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.into_iter().map(|t| t.title).collect())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
chunks,
|
||||||
|
vec![
|
||||||
|
vec!["one".to_string(), "two".into()],
|
||||||
|
vec!["three".to_string()],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- settings -------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settings_round_trip_through_the_config_write_back() {
|
||||||
|
let (client, dir) = client_with_root().await;
|
||||||
|
let written = client.settings();
|
||||||
|
let parsed: Settings = toml::from_str(&written).expect("settings TOML");
|
||||||
|
assert_eq!(parsed.root.as_deref(), Some(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn init_accepts_a_root_that_does_not_exist_yet() {
|
||||||
|
let settings = "root = \"/definitely/not/there\"\n";
|
||||||
|
let client = Client::init(settings).await.expect("init");
|
||||||
|
let err = client.get_lib_node("/fs").await.expect_err("listing fails");
|
||||||
|
assert_eq!(err, ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Plan: fs-provider
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs`
|
||||||
|
and/or gates in `quality/fs-provider.md`). Stubs, tests, and workspace
|
||||||
|
wiring exist; all 13 fsdy tests fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — `TrackFile::parse` + `playable()` validation.** TOML parse
|
||||||
|
into the schema structs; `playable()` enforces exactly-one, http(s)
|
||||||
|
scheme, absolute link, no `/fs` link. Verifies:
|
||||||
|
`parse_accepts_a_full_track_file`, `parse_defaults_optional_metadata`,
|
||||||
|
`parse_rejects_wrong_playable_cardinality`,
|
||||||
|
`parse_rejects_invalid_playables`; gate "link chains impossible".
|
||||||
|
- [x] **T2 — `TrackFile::to_track`.** Single conversion site; link rewrites
|
||||||
|
the path, file/url keep it; album/duration mapped. Verifies:
|
||||||
|
`to_track_rewrites_the_path_only_for_links`; gate "single
|
||||||
|
file-to-Track conversion".
|
||||||
|
- [x] **T3 — path scheme: `is_track_path` + `disk_path`.** Suffix+prefix
|
||||||
|
check; decode-validate-join with `.`/`..`/empty/separator rejection in
|
||||||
|
one helper. Verifies: `track_paths_need_the_suffix_and_the_provider_prefix`,
|
||||||
|
`client_paths_cannot_escape_the_root`; gate "one validation place".
|
||||||
|
- [x] **T4 — `init` + `settings`.** Parse `Settings`, default root
|
||||||
|
`dirs::audio_dir()`, `Config` error when neither; nonexistent root
|
||||||
|
accepted; write-back serialization. Verifies:
|
||||||
|
`settings_round_trip_through_the_config_write_back`,
|
||||||
|
`init_accepts_a_root_that_does_not_exist_yet`.
|
||||||
|
- [x] **T5 — `read_track_file` + `get_metadata_for_track` +
|
||||||
|
`get_urls_for_track`.** tokio::fs read, parse, playable dispatch
|
||||||
|
(relative file joined onto the track file's dir; link →
|
||||||
|
`MalformedPath` warn). Verifies: `urls_resolve_per_playable_kind`,
|
||||||
|
`metadata_of_a_link_track_carries_the_target_path`; gates "plain
|
||||||
|
paths/URLs only", "no file contents in logs".
|
||||||
|
- [x] **T6 — `list_dir` + `get_lib_node` + `get_lib_root`.** Sorted
|
||||||
|
case-insensitive listing; skip symlinks/hidden/non-UTF-8/foreign/
|
||||||
|
broken (warn with file name); encoded child paths; parent links.
|
||||||
|
Verifies: `listing_sorts_and_skips_foreign_hidden_and_broken_entries`,
|
||||||
|
`listing_a_missing_directory_is_an_error_not_a_panic`,
|
||||||
|
`nodes_link_back_to_their_parent`,
|
||||||
|
`resolving_a_tree_streams_chunks_in_listing_order`; gates "symlinks
|
||||||
|
skipped", "listing order = resolve order", "tokio::fs only".
|
||||||
|
- [x] **T7 — orchestrator wiring.** `fs_client: Option<Arc<fsdy::Client>>`
|
||||||
|
in `ProviderOrchestrator`; non-fatal init from `fsdy.toml` with
|
||||||
|
write-back; `/fs` arms in every trait method; root child only when
|
||||||
|
present; `crabidy-server/Cargo.toml` gains `fsdy`. Verifies: gates
|
||||||
|
under "Orchestrator wiring"; workspace build.
|
||||||
|
- [x] **T8 — full verification.** All fsdy tests + whole workspace suite
|
||||||
|
green; clippy/fmt/taplo/markdownlint clean; walk the remaining gates
|
||||||
|
and tick them; no `todo!()` left.
|
||||||
|
- [x] **T9 — live smoke test.** Build a real tree under a temp root
|
||||||
|
(nested dirs, a relative-file track, a url track, a `/tidal` link
|
||||||
|
track), run the server pointing at it, list `/fs` over the provider
|
||||||
|
layer, and resolve a directory — remove any temporary probe
|
||||||
|
afterwards. Verifies: end-to-end behavior of D2/D4 outside unit
|
||||||
|
scope.
|
||||||
|
- [x] **T10 — docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/fs-provider.md` if the implementation
|
||||||
|
diverged.
|
||||||
|
|
@ -1,5 +1,52 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## fs-provider (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/fs-provider.md`: a second media provider (crate `fsdy`,
|
||||||
|
`/fs`) that walks one configured root directory and treats
|
||||||
|
`*.track.toml` files as serialized track nodes — metadata plus exactly
|
||||||
|
one playable reference: a local audio file (absolute or relative to the
|
||||||
|
track file), an http(s) URL, or a crabidy-internal link. The wire types
|
||||||
|
are unchanged (architecture D1): the only new datastructure is the
|
||||||
|
on-disk TOML schema. Link tracks rewrite `Track.path` to the target at
|
||||||
|
listing time (D2), so playback routes to the owning provider through the
|
||||||
|
orchestrator's existing prefix routing with zero new mechanisms; links
|
||||||
|
into `/fs` are rejected at parse time, making chains impossible.
|
||||||
|
Directories list sorted and queue via the default chunked resolve walk;
|
||||||
|
client paths are decoded and validated in a single helper so they cannot
|
||||||
|
escape the root; symlinks, hidden entries, and broken files are skipped
|
||||||
|
with warnings. `ProviderOrchestrator` gained an optional fs client
|
||||||
|
(non-fatal init from `fsdy.toml`, default root `dirs::audio_dir()`) and
|
||||||
|
`/fs` routing arms in every trait method. No player or TUI changes were
|
||||||
|
needed. All 91 workspace tests green (15 new in `fsdy`); every gate in
|
||||||
|
`quality/fs-provider.md` checked. A temporary live probe (removed after
|
||||||
|
passing) built a real tree whose link track pointed at a track fetched
|
||||||
|
from the live Tidal API: listing order held, the link path was
|
||||||
|
rewritten, and the target resolved a stream URL — the full D2 story
|
||||||
|
end-to-end.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions
|
||||||
|
are recorded in `architecture/fs-provider.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (fs-provider)
|
||||||
|
|
||||||
|
- **`TrackFileError::UrlScheme` carries only the scheme**, not the URL:
|
||||||
|
the parse error ends up in skip-warnings, and a private stream URL may
|
||||||
|
embed a token (quality gate "no file contents in logs"). The
|
||||||
|
architecture's schema and behavior are otherwise as designed.
|
||||||
|
- **The live probe ran at the provider layer**, not against a running
|
||||||
|
server (no interactive terminal/audio device here, same as previous
|
||||||
|
features): `fsdy` and `tidaldy` clients driven directly, mimicking the
|
||||||
|
orchestrator's routing exactly. It also had to *fetch* its link target
|
||||||
|
first — the well-known id from the progressive-queueing probe is an
|
||||||
|
album path, and a link must point at a track.
|
||||||
|
- **`get_lib_node` on a track path is `MalformedPath`** — implicit in
|
||||||
|
the design, made explicit so the default resolve walk can never
|
||||||
|
mistake a track file for a directory.
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo
|
||||||
|
change.
|
||||||
|
|
||||||
## progressive-queueing (2026-07-21)
|
## progressive-queueing (2026-07-21)
|
||||||
|
|
||||||
Built per `plan/progressive-queueing.md`: queueing a large nested collection
|
Built per `plan/progressive-queueing.md`: queueing a large nested collection
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Quality gates: fs-provider
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automated tests in
|
||||||
|
`fsdy/src/lib.rs`. Check each by reading the code and reasoning; tick only
|
||||||
|
when verified.
|
||||||
|
|
||||||
|
## Robustness (hard rules)
|
||||||
|
|
||||||
|
- [x] No code path panics on the contents of the tree: unreadable files,
|
||||||
|
invalid TOML, invalid UTF-8 file names, symlink cycles, and permission
|
||||||
|
errors all end in a typed error or a warn-and-skip — no `unwrap`/
|
||||||
|
`expect`/`panic!`/indexing on tree- or client-derived data outside
|
||||||
|
tests.
|
||||||
|
- [x] Client-supplied paths cannot address anything outside the configured
|
||||||
|
root: every decoded segment is validated (`.`, `..`, empty, separator)
|
||||||
|
before joining, in **one** place that all lookups go through.
|
||||||
|
- [x] All provider I/O is `tokio::fs` — no `std::fs` outside `#[cfg(test)]`
|
||||||
|
(nothing blocks the runtime).
|
||||||
|
- [x] Directory listing skips symlinks without following them (no cycle can
|
||||||
|
hang the walk; the root cannot be escaped via links).
|
||||||
|
|
||||||
|
## Contract fidelity
|
||||||
|
|
||||||
|
- [x] A `link` playable rewrites `Track.path` to the target everywhere a
|
||||||
|
`Track` is built (listing and metadata) — there is a single
|
||||||
|
file-to-`Track` conversion both call.
|
||||||
|
- [x] `link` targets pointing into `/fs` are rejected at parse time, so
|
||||||
|
link chains are structurally impossible.
|
||||||
|
- [x] `get_urls_for_track` returns plain file paths and http(s) URLs only —
|
||||||
|
never `file://` URLs (the player rejects unknown schemes).
|
||||||
|
- [x] A broken track file is skipped **with a warning that names the file**
|
||||||
|
and does not remove its siblings from the listing.
|
||||||
|
- [x] `create/rename/delete_lib_node` return `NotSupported`; no fs mutation
|
||||||
|
API sneaks in.
|
||||||
|
- [x] `resolve_tracks_into` keeps the trait default (no override), and the
|
||||||
|
listing order equals the resolve order (both come from the same sorted
|
||||||
|
listing).
|
||||||
|
|
||||||
|
## Orchestrator wiring
|
||||||
|
|
||||||
|
- [x] Every `ProviderClient` method on `ProviderOrchestrator` routes `/fs`
|
||||||
|
and `/fs/...` to the fs client, mirroring the `/tidal` arms (including
|
||||||
|
`is_track_path` and `resolve_tracks_into`).
|
||||||
|
- [x] fs init failure is non-fatal: the server starts, logs a warning, and
|
||||||
|
`/fs` is absent from the root listing; no `/fs`-routing arm can panic
|
||||||
|
when the client is absent.
|
||||||
|
- [x] The `fsdy.toml` config is written back with effective defaults on
|
||||||
|
first run, like `tidaly.toml`.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] Every public item in `fsdy` has a doc comment stating intent and
|
||||||
|
error behavior; the on-disk format is documented where the schema type
|
||||||
|
is defined.
|
||||||
|
- [x] Warnings/errors never include file *contents* (a track file may hold
|
||||||
|
a private URL with a token) — log paths and error kinds, not bodies.
|
||||||
|
- [x] `cargo clippy` is warning-free; `cargo fmt`, `taplo`, `markdownlint`
|
||||||
|
clean; no `todo!()`/`unimplemented!()` remains.
|
||||||
|
- [x] The whole workspace test suite passes, not just `fsdy`.
|
||||||
Loading…
Reference in New Issue