From 4dd5f01217c102bd60b0865588b827f0e5a578f7 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 21 Jul 2026 01:58:55 +0200 Subject: [PATCH] Add a filesystem provider for serialized track nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 18 + Cargo.toml | 3 + architecture/fs-provider.md | 244 ++++++++++ crabidy-server/Cargo.toml | 1 + crabidy-server/src/provider.rs | 72 +++ fsdy/Cargo.toml | 20 + fsdy/src/lib.rs | 792 +++++++++++++++++++++++++++++++++ plan/fs-provider.md | 56 +++ plan/summary.md | 47 ++ quality/fs-provider.md | 59 +++ 10 files changed, 1312 insertions(+) create mode 100644 architecture/fs-provider.md create mode 100644 fsdy/Cargo.toml create mode 100644 fsdy/src/lib.rs create mode 100644 plan/fs-provider.md create mode 100644 quality/fs-provider.md diff --git a/Cargo.lock b/Cargo.lock index 748f3e3..0c38446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,6 +745,7 @@ dependencies = [ "crabidy-core", "dirs", "flume", + "fsdy", "futures", "rand 0.10.2", "tidaldy", @@ -1223,6 +1224,23 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures" version = "0.3.33" diff --git a/Cargo.toml b/Cargo.toml index 2a0df3d..71f5173 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "cbd-tui", "crabidy-core", "crabidy-server", + "fsdy", "tidaldy", ] @@ -49,6 +50,7 @@ stream-download = { version = "0.24", default-features = false, features = [ "reqwest-rustls", "temp-storage", ] } +tempfile = "3" thiserror = "2" tokio = "1" tokio-stream = "0.1" @@ -64,4 +66,5 @@ url = "2" # Local crates audio-player = { path = "audio-player" } crabidy-core = { path = "crabidy-core" } +fsdy = { path = "fsdy" } tidaldy = { path = "tidaldy" } diff --git a/architecture/fs-provider.md b/architecture/fs-provider.md new file mode 100644 index 0000000..318ef15 --- /dev/null +++ b/architecture/fs-provider.md @@ -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 `.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//` layer). +- Paths: `/fs///…` 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>` 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. diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index 4298b80..27dc2e8 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -14,6 +14,7 @@ audio-player.workspace = true crabidy-core.workspace = true dirs.workspace = true flume.workspace = true +fsdy.workspace = true futures.workspace = true rand.workspace = true tidaldy.workspace = true diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 4bde849..6911d85 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -12,9 +12,25 @@ pub struct ProviderOrchestrator { pub provider_tx: flume::Sender, provider_rx: flume::Receiver, tidal_client: Arc, + /// `None` when the filesystem provider failed to initialize — the + /// server runs without `/fs` instead of dying (architecture D5). + fs_client: Option>, +} + +/// Whether a path belongs to the filesystem provider. +fn fs_owns(path: &str) -> bool { + path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/") } 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) { tokio::spawn(async move { // 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 { 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); Ok(Self { provider_rx, provider_tx, tidal_client, + fs_client, }) } @@ -136,6 +170,12 @@ impl ProviderClient for ProviderOrchestrator { if path == "/tidal" || path.starts_with("/tidal/") { 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 } @@ -144,6 +184,9 @@ impl ProviderClient for ProviderOrchestrator { if track_path.starts_with("/tidal/") { 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"); Err(ProviderError::MalformedPath) } @@ -153,6 +196,9 @@ impl ProviderClient for ProviderOrchestrator { if track_path.starts_with("/tidal/") { 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"); Err(ProviderError::MalformedPath) } @@ -162,6 +208,11 @@ impl ProviderClient for ProviderOrchestrator { let child = LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false); 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 } @@ -174,6 +225,9 @@ impl ProviderClient for ProviderOrchestrator { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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"); Err(ProviderError::MalformedPath) } @@ -189,6 +243,12 @@ impl ProviderClient for ProviderOrchestrator { if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") { 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"); Err(ProviderError::NotSupported) } @@ -204,6 +264,9 @@ impl ProviderClient for ProviderOrchestrator { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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"); Err(ProviderError::NotSupported) } @@ -219,6 +282,12 @@ impl ProviderClient for ProviderOrchestrator { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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"); Err(ProviderError::MalformedPath) } @@ -230,6 +299,9 @@ impl ProviderClient for ProviderOrchestrator { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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"); Err(ProviderError::NotSupported) } diff --git a/fsdy/Cargo.toml b/fsdy/Cargo.toml new file mode 100644 index 0000000..40bb2ca --- /dev/null +++ b/fsdy/Cargo.toml @@ -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"] } diff --git a/fsdy/src/lib.rs b/fsdy/src/lib.rs new file mode 100644 index 0000000..5b348f4 --- /dev/null +++ b/fsdy/src/lib.rs @@ -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, +} + +/// 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, + /// Optional album metadata. + pub album: Option, + /// 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, +} + +/// 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, + /// http(s) URL streamed by the player. + pub url: Option, + /// Absolute crabidy track path owned by another provider + /// (e.g. `/tidal/...`). Links into `/fs` are rejected (no chains). + pub link: Option, +} + +/// 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 { + 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 { + 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(|_| "".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 { + 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 { + 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 = Vec::new(); + let mut track_names: Vec = 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + Err(ProviderError::NotSupported) + } + + /// Not supported — track files are deleted with normal file tools. + async fn delete_lib_node(&self, _path: &str) -> Result { + 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> = 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); + } +} diff --git a/plan/fs-provider.md b/plan/fs-provider.md new file mode 100644 index 0000000..978b2e9 --- /dev/null +++ b/plan/fs-provider.md @@ -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>` + 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. diff --git a/plan/summary.md b/plan/summary.md index 6ba57e9..7ade29b 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1,5 +1,52 @@ # 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) Built per `plan/progressive-queueing.md`: queueing a large nested collection diff --git a/quality/fs-provider.md b/quality/fs-provider.md new file mode 100644 index 0000000..f92b81d --- /dev/null +++ b/quality/fs-provider.md @@ -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`.