//! 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 = ".cbd-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`, `store`, `skipped = true`")] PlayableCardinality, #[error("playable store name must be a non-empty bare file name: {0}")] StoreName(String), /// 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("cannot serialize track file: {0}")] Serialize(#[from] toml::ser::Error), } /// The on-disk schema of a `*.cbd-track.toml` file. See /// `architecture/fs-provider.md` (D3) for the format documentation. /// /// Serializable both ways: queue persistence writes these files (see /// `architecture/queue-persistence.md` D2). `Option` fields are skipped on /// serialization — TOML has no null. #[derive(Debug, Deserialize, Serialize)] 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. #[serde(skip_serializing_if = "Option::is_none")] pub duration: Option, /// Optional album metadata. #[serde(skip_serializing_if = "Option::is_none")] pub album: Option, /// Provider-internal id of the source item (`Track.provider_item_id`), /// preserved so it survives a link/queue/bookmark round trip. Without it /// a later capture of a linked track cannot de-duplicate by provider id /// *before* downloading — it falls back to hashing the fetched bytes /// (architecture/crabidy-store.md D3/D4). Omitted when the source has /// none (e.g. a local `/fs` file). #[serde(skip_serializing_if = "Option::is_none")] pub provider_item_id: Option, /// The playable reference; exactly one of its fields must be set. pub playable: PlayableSpec, } /// Optional `[album]` table of a track file. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct AlbumMeta { pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub release_date: Option, } /// Raw `[playable]` table: four optional fields so cardinality errors are /// precise. Validated into a [`Playable`] before use. #[derive(Debug, Deserialize, Serialize)] pub struct PlayableSpec { /// Local audio file; absolute, or relative to the track file's /// directory. #[serde(skip_serializing_if = "Option::is_none")] pub file: Option, /// http(s) URL streamed by the player. #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, /// Absolute crabidy track path, usually owned by another provider /// (e.g. `/tidal/...`). Links resolve **one hop** by construction: /// `get_urls_for_track` never follows a link, so a link whose target is /// itself a link file fails at play time and cycles cannot recurse. #[serde(skip_serializing_if = "Option::is_none")] pub link: Option, /// `true` marks the track as having no playable audio: a capture /// recorded its source as uncapturable (architecture/ /// incremental-captures.md D1). Must be the only field set; `false` /// counts as unset and fails cardinality validation. #[serde(skip_serializing_if = "Option::is_none")] pub skipped: Option, /// Name of an entry in the content-addressed store (a bare file name, /// no separators), resolved against the instance's store root at play /// time. Written by download captures instead of `file`, so audio is /// shared and de-duplicated across saves /// (architecture/crabidy-store.md D2). #[serde(skip_serializing_if = "Option::is_none")] pub store: Option, } /// A validated playable reference. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Playable { File(PathBuf), Url(String), Link(String), /// A content-store entry name, resolved against the instance's store /// root (architecture/crabidy-store.md D2). Store(String), /// No playable audio; playback skips the track. Skipped, } 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 { // `skipped = false` counts as unset — only `true` marks a track. let skipped = match self.playable.skipped { Some(true) => Some(()), _ => None, }; match ( &self.playable.file, &self.playable.url, &self.playable.link, &self.playable.store, skipped, ) { (Some(file), None, None, None, None) => Ok(Playable::File(file.clone())), (None, Some(url), None, None, 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), None, None) => { if !link.starts_with('/') { return Err(TrackFileError::LinkNotAbsolute(link.clone())); } Ok(Playable::Link(link.clone())) } (None, None, None, Some(name), None) => { // A store entry is a bare file name; separators would let a // toml escape the store root at resolve time. if name.is_empty() || name.contains(['/', '\\', '\0']) { return Err(TrackFileError::StoreName(name.clone())); } Ok(Playable::Store(name.clone())) } (None, None, None, None, Some(())) => Ok(Playable::Skipped), _ => 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, URL, and /// skipped playables keep `lib_path`; a skipped playable additionally /// sets the wire track's `is_skipped` flag. pub fn to_track(&self, lib_path: &str) -> Track { let playable = self.playable(); let path = match &playable { Ok(Playable::Link(target)) => target.clone(), _ => 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(), }), is_skipped: matches!(playable, Ok(Playable::Skipped)), // Restored from the toml when present (a link/queue/bookmark keeps // the source's provider id so a later capture can de-dup by id); // empty when the source had none. provider_item_id: self.provider_item_id.clone().unwrap_or_default(), // A store-backed track is captured by construction; other // playables are marked (or not) by the server's store index. is_captured: matches!(playable, Ok(Playable::Store(_))), } } /// The inverse of [`Self::to_track`] for persistence: a track file whose /// metadata is copied from `track` and whose playable is a /// [`Playable::Link`] to `track.path` — uniformly, for every track (see /// `architecture/queue-persistence.md` D2). Round trip: /// `from_track(t).to_track(anywhere)` yields `t` again, because links /// rewrite the path back to the target at listing time. /// /// A skipped source track (`is_skipped`) writes a skipped playable /// instead of a link: there is nothing behind it to link to, and /// persisted queues and bookmarks must keep the skipped marking /// (architecture/incremental-captures.md D1). pub fn from_track(track: &Track) -> Self { let playable = if track.is_skipped { PlayableSpec { file: None, url: None, link: None, store: None, skipped: Some(true), } } else { PlayableSpec { file: None, url: None, link: Some(track.path.clone()), store: None, skipped: None, } }; Self { title: track.title.clone(), artist: track.artist.clone(), duration: track.duration, album: track.album.as_ref().map(|a| AlbumMeta { title: a.title.clone(), release_date: a.release_date.clone(), }), // Keep the source's provider id so a capture of this saved entry // can de-dup by id before downloading (crabidy-store.md D4). provider_item_id: (!track.provider_item_id.is_empty()) .then(|| track.provider_item_id.clone()), playable, } } /// Like [`Self::from_track`], but the playable is a local /// [`Playable::File`] at `file` instead of a link — used by download /// captures, where the audio sits next to the toml and `file` is its /// relative name (see `architecture/captures.md` D3). Relative paths /// resolve against the track file's directory at play time, so the /// capture folder stays relocatable. pub fn from_track_with_file(track: &Track, file: &Path) -> Self { let mut this = Self::from_track(track); this.playable = PlayableSpec { file: Some(file.to_path_buf()), url: None, link: None, store: None, skipped: None, }; this } /// Like [`Self::from_track`], but the playable is a [`Playable::Store`] /// entry name — used by download captures whose audio lives in the /// shared content store, so many saves reference one file /// (architecture/crabidy-store.md D2/D4). `name` must be a bare file /// name (validated by [`Self::playable`] on read). pub fn from_track_store(track: &Track, name: &str) -> Self { let mut this = Self::from_track(track); this.playable = PlayableSpec { file: None, url: None, link: None, store: Some(name.to_string()), skipped: None, }; this } /// Like [`Self::from_track`], but the playable is [`Playable::Skipped`] /// regardless of the source track — used by download captures to record /// an uncapturable track instead of silently omitting it /// (architecture/incremental-captures.md D2). pub fn from_track_skipped(track: &Track) -> Self { let mut this = Self::from_track(track); this.playable = PlayableSpec { file: None, url: None, link: None, store: None, skipped: Some(true), }; this } /// Serializes this file to TOML text. /// /// Only fails when TOML cannot represent the value /// ([`TrackFileError::Serialize`]); never panics. pub fn to_toml(&self) -> Result { Ok(toml::to_string_pretty(self)?) } } /// The shared name builder behind [`track_file_name`] and [`dir_name`]: a /// zero-padded four-digit one-based order prefix plus a sanitized `title`. /// /// The prefix makes the case-insensitive listing sort reproduce the /// source's order (wrong beyond 9999 entries — accepted, see /// `architecture/queue-persistence.md`). Sanitizing replaces path /// separators and NUL, strips leading dots (hidden entries are skipped by /// listings), and falls back to `track` for an empty result. Never panics. fn ordered_name(index: usize, title: &str) -> String { let sanitized: String = title .chars() .map(|c| { if matches!(c, '/' | '\\' | '\0') { '_' } else { c } }) .collect(); let sanitized = sanitized.trim_start_matches('.').trim(); let name = if sanitized.is_empty() { "track" } else { sanitized }; format!("{:04} {name}", index + 1) } /// File name for the serialized queue entry at zero-based `index`: /// [`ordered_name`] plus [`TRACK_FILE_SUFFIX`] — e.g. /// `0001 Bohemian Rhapsody.cbd-track.toml`. pub fn track_file_name(index: usize, title: &str) -> String { format!("{}{TRACK_FILE_SUFFIX}", ordered_name(index, title)) } /// Folder name for the captured child node at zero-based `index`: the same /// prefix and sanitizer as [`track_file_name`], without the suffix — e.g. /// `0001 News of the World`. pub fn dir_name(index: usize, title: &str) -> String { ordered_name(index, title) } /// Validates a user-supplied folder name for an fs-provider tree (bookmark /// or saved-queue names, rename targets), returning the trimmed name. /// /// Rejected (with a human-readable reason): empty after trimming, /// containing `/`, `\` or NUL, starting with a dot (hidden folders are /// invisible to listings), or matching one of `reserved` (e.g. the /// auto-persisted `current` queue). pub fn validate_folder_name<'a>(name: &'a str, reserved: &[&str]) -> Result<&'a str, &'static str> { let name = name.trim(); if name.is_empty() { return Err("must not be empty"); } if name.contains(['/', '\\', '\0']) { return Err("must not contain a path separator or NUL"); } if name.starts_with('.') { return Err("must not start with a dot (hidden folders are invisible)"); } if reserved.contains(&name) { return Err("is a reserved name"); } Ok(name) } /// 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. /// /// A process may mount several instances, each serving one disk root under /// its own provider root: the configured `/fs` instance (built by /// [`ProviderClient::init`] from `fsdy.toml`) and the server's `/queues` /// instance over the persisted-queues folder (see /// `architecture/queue-persistence.md` D1). #[derive(Debug)] pub struct Client { root: PathBuf, /// First library path segment this instance owns, e.g. `/fs`. provider_root: String, /// Whether direct children of the instance root may be renamed and /// deleted through the library (bookmarks, saved queues). Off by /// default — `/fs` trees are managed with file tools. editable_top_level: bool, /// Folder names exempt from top-level editing (e.g. the auto-persisted /// `current` queue) and rejected as rename targets. reserved: Vec, /// Whether this instance's nodes advertise download captures /// (`is_downloadable`, architecture/captures.md D4) — on for /// `/queues` and `/bookmarks`, whose entries mostly link to /// downloadable providers; off for `/fs` and `/captures`. Tracks /// whose source cannot be captured are skipped by the capture walk. downloadable_nodes: bool, /// Whether the whole tree is deletable through the library: every /// folder below the instance root and every track file (which loses /// its local audio along with its metadata). On for `/captures` /// only — deletes there destroy downloaded data, so clients confirm /// them (architecture/capture-deletion.md). deletable_tree: bool, /// Root of the content-addressed store this instance resolves /// [`Playable::Store`] entries against (architecture/crabidy-store.md). /// Set only on the `/crabidy` instance; `None` elsewhere, where a store /// playable is a malformed reference. store_root: Option, } impl Client { /// Builds an instance serving `disk_root` under `provider_root`. /// /// `provider_root` must be a single absolute segment (`/name`, no /// trailing slash, no inner slash) and `disk_root` an absolute path; /// anything else is [`ProviderError::Config`]. A `disk_root` that does /// not exist (yet) is accepted — listing it just fails until it appears. pub fn new(provider_root: &str, disk_root: PathBuf) -> Result { let is_single_absolute_segment = provider_root.len() > 1 && provider_root.starts_with('/') && !provider_root[1..].contains('/'); if !is_single_absolute_segment { return Err(ProviderError::Config(format!( "provider root must be a single absolute segment like /fs: {provider_root}" ))); } if !disk_root.is_absolute() { return Err(ProviderError::Config(format!( "disk root must be an absolute path: {}", disk_root.display() ))); } Ok(Self { root: disk_root, provider_root: provider_root.to_string(), editable_top_level: false, reserved: Vec::new(), downloadable_nodes: false, deletable_tree: false, store_root: None, }) } /// Enables renaming and deleting the instance root's direct child /// folders through the library (`is_editable`/`is_deletable` on the /// root listing, [`ProviderClient::rename_lib_node`] / /// [`ProviderClient::delete_lib_node`]). Names in `reserved` keep /// their immutability and are rejected as rename targets. Deeper /// levels always stay immutable (architecture/bookmarks.md D4). pub fn with_editable_top_level(mut self, reserved: &[&str]) -> Self { self.editable_top_level = true; self.reserved = reserved.iter().map(|s| s.to_string()).collect(); self } /// Marks every node of this instance downloadable (`is_downloadable` /// — `W` captures the subtree, see architecture/captures.md D4). Used /// by `/queues` and `/bookmarks`, whose entries are links into /// downloadable providers; the capture walk skips tracks whose /// source cannot be captured (local files, unresolvable links). pub fn with_downloadable_nodes(mut self) -> Self { self.downloadable_nodes = true; self } /// Makes the whole tree deletable through the library /// ([`ProviderClient::delete_lib_node`]): every folder below the /// instance root (recursively, any depth) and every track file. A /// deleted track loses its metadata file *and* its local audio, as /// long as the audio resolves inside the instance root. Used by /// `/captures`, where stale downloads are reclaimed through the /// library (architecture/capture-deletion.md). pub fn with_deletable_tree(mut self) -> Self { self.deletable_tree = true; self } /// Sets the content-store root against which [`Playable::Store`] entries /// resolve (architecture/crabidy-store.md). Used by the `/crabidy` /// instance; without it, a store playable resolves to /// [`ProviderError::MalformedPath`]. pub fn with_store_root(mut self, store_root: PathBuf) -> Self { self.store_root = Some(store_root); self } /// The provider root this instance owns (e.g. `/fs`), as configured /// through [`Self::new`]. pub fn provider_root(&self) -> &str { &self.provider_root } /// The disk root this instance serves. Exposed so the server can hand a /// file provider's root to the `/orphans` provider as a reference root to /// scan for `Playable::Store` links (architecture/orphans.md). pub fn disk_root(&self) -> &Path { &self.root } /// The display title of this instance's root node: the provider root /// without its leading slash (`fs`, `queues`). fn root_title(&self) -> &str { self.provider_root.trim_start_matches('/') } /// Maps a library path under this instance's provider root 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 == self.provider_root { "" } else { lib_path .strip_prefix(self.provider_root.as_str()) .and_then(|rest| rest.strip_prefix('/')) .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, `*.cbd-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)); } } // Only an editable instance's direct root children are modifiable // (architecture/bookmarks.md D4); reserved names (the auto-persisted // `current` queue) keep their immutability. A deletable tree // additionally makes every folder deletable (but not renamable). let editable_here = self.editable_top_level && lib_path == self.provider_root; let children = dir_names .into_iter() .map(|name| { let child_lib = crabidy_core::join_path(lib_path, &crabidy_core::encode_segment(&name)); let editable = editable_here && !self.reserved.contains(&name); let mut child = LibraryNodeChild::new(child_lib, name, true); child.is_editable = editable; child.is_deletable = editable || self.deletable_tree; child.is_downloadable = self.downloadable_nodes; child }) .collect(); let title = if lib_path == self.provider_root { self.root_title().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, is_downloadable: self.downloadable_nodes, tracks_deletable: self.deletable_tree, is_captured: false, }) } /// The on-disk folder and decoded name of `path`, if it is a /// modifiable top-level child of this instance: the editable option is /// on, the path addresses a direct child folder of the instance root, /// and the name is not reserved. Everything else is /// [`ProviderError::NotSupported`] — exactly matching the /// `is_editable`/`is_deletable` flags the listing advertised. fn editable_child_dir(&self, path: &str) -> Result<(PathBuf, String), ProviderError> { if !self.editable_top_level || self.is_track_path(path) { return Err(ProviderError::NotSupported); } let rest = path .strip_prefix(self.provider_root.as_str()) .and_then(|rest| rest.strip_prefix('/')) .ok_or(ProviderError::NotSupported)?; if rest.is_empty() || rest.contains('/') { return Err(ProviderError::NotSupported); } let name = crabidy_core::decode_segment(rest); if self.reserved.contains(&name) { return Err(ProviderError::NotSupported); } // The single traversal-validation site still guards the segment. Ok((self.disk_path(path)?, name)) } /// Whether `path` addresses a reserved direct child of the instance /// root (e.g. the auto-persisted `current` queue) — immutable even on /// a deletable tree. fn is_reserved_top_level(&self, path: &str) -> bool { path.strip_prefix(self.provider_root.as_str()) .and_then(|rest| rest.strip_prefix('/')) .is_some_and(|rest| { !rest.contains('/') && self.reserved.contains(&crabidy_core::decode_segment(rest)) }) } /// Deletes one track file and, when its playable is a local audio /// file that resolves inside the instance root, that audio file too /// (captures store audio next to the metadata; audio elsewhere on /// disk — an `/fs`-style absolute reference — is never touched). /// Idempotent: an already-gone track file is a success; a track file /// that no longer parses still gets removed (its audio cannot be /// located then, which the log notes). async fn delete_track_file(&self, path: &str) -> Result<(), ProviderError> { let disk = self.disk_path(path)?; let audio = match self.read_track_file(&disk).await { Ok(file) => match file.playable() { Ok(Playable::File(target)) => { let absolute = if target.is_absolute() { target } else { disk.parent().map(|dir| dir.join(&target)).unwrap_or(target) }; Some(absolute) } _ => None, }, // read_track_file warned already; a missing file stays // idempotent below, an unparseable one is deleted blind. Err(_) => None, }; match tokio::fs::remove_file(&disk).await { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(()); } Err(err) => { warn!(path, "cannot delete track file: {err}"); return Err(ProviderError::InternalError); } } let Some(audio) = audio else { return Ok(()); }; // Containment check on the *resolved* audio path: canonicalize // both sides so `..` segments or symlinks in the track file // cannot direct the delete outside the instance root. let Ok(root) = tokio::fs::canonicalize(&self.root).await else { return Ok(()); }; match tokio::fs::canonicalize(&audio).await { Ok(canonical) if canonical.starts_with(&root) => { if let Err(err) = tokio::fs::remove_file(&canonical).await { if err.kind() != std::io::ErrorKind::NotFound { warn!(path, "track file deleted, but not its audio: {err}"); } } } Ok(_) => { warn!(path, "keeping audio outside the instance root"); } // Already gone (or unreadable): nothing left to delete. Err(_) => {} } Ok(()) } /// 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(), ) })?; // `new` enforces the absolute-path requirement for `root`. Self::new(PROVIDER_ROOT, 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() }) } /// Paths under this instance's provider root ending in /// [`TRACK_FILE_SUFFIX`] address tracks. fn is_track_path(&self, path: &str) -> bool { path.strip_prefix(self.provider_root.as_str()) .is_some_and(|rest| rest.starts_with('/')) && 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. /// [`Playable::Skipped`] has no audio and is [`ProviderError::FetchError`] /// — playback normally never asks (it skips on the track's wire flag). 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::Store(name) => { // Resolve against the instance's store root; the name was // validated as a bare file name, so it stays inside. let Some(store_root) = self.store_root.as_ref() else { warn!( path = track_path, "store playable outside the crabidy provider" ); return Err(ProviderError::MalformedPath); }; let path = store_root .join(&name) .to_str() .ok_or(ProviderError::InternalError)? .to_string(); Ok(vec![path]) } 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) } Playable::Skipped => { warn!(path = track_path, "skipped tracks have no audio"); Err(ProviderError::FetchError) } } } 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 root node for this instance; 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: self.provider_root.clone(), title: self.root_title().to_string(), children: Vec::new(), parent: Some(crabidy_core::ROOT_PATH.to_string()), tracks: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: self.downloadable_nodes, tracks_deletable: self.deletable_tree, is_captured: 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) } /// Renames a modifiable top-level folder (editable instances only, see /// [`Self::with_editable_top_level`]); everything else is /// [`ProviderError::NotSupported`]. /// /// The new title is validated like every store name (reserved names /// rejected as targets); renaming onto an existing sibling is /// [`ProviderError::InvalidInput`] — folders never merge. Renaming to /// the current name is a no-op. Returns the renamed node at its new /// path. async fn rename_lib_node( &self, path: &str, new_title: &str, ) -> Result { let (disk, name) = self.editable_child_dir(path)?; let reserved: Vec<&str> = self.reserved.iter().map(String::as_str).collect(); let new_name = validate_folder_name(new_title, &reserved).map_err(|reason| { warn!(path, new_title, "rejecting rename: name {reason}"); ProviderError::InvalidInput })?; let new_path = crabidy_core::join_path(&self.provider_root, &crabidy_core::encode_segment(new_name)); if new_name == name { return self.get_lib_node(&new_path).await; } // `validate_folder_name` rejected separators, NUL, and dot // prefixes, so the target is a plain sibling name. let target = self.root.join(new_name); let taken = tokio::fs::try_exists(&target).await.map_err(|err| { warn!(path, "cannot check rename target: {err}"); ProviderError::InternalError })?; if taken { warn!(path, new_name, "rejecting rename onto an existing folder"); return Err(ProviderError::InvalidInput); } tokio::fs::rename(&disk, &target).await.map_err(|err| { warn!(path, "cannot rename folder: {err}"); ProviderError::InternalError })?; self.get_lib_node(&new_path).await } /// Deletes a modifiable top-level folder (editable instances), or — /// on a deletable tree ([`Self::with_deletable_tree`]) — any nested /// folder recursively or a single track file together with its local /// audio. Everything else is [`ProviderError::NotSupported`]. /// Idempotent: an already-gone target is a success. Returns the /// refreshed parent listing. async fn delete_lib_node(&self, path: &str) -> Result { if self.deletable_tree && self.is_track_path(path) { self.delete_track_file(path).await?; } else { let disk = match self.editable_child_dir(path) { Ok((disk, _name)) => disk, // Below the top level only a deletable tree may delete — // never the instance root itself, and reserved names keep // their immutability even there. Err(ProviderError::NotSupported) if self.deletable_tree && path != self.provider_root && !self.is_reserved_top_level(path) => { self.disk_path(path)? } Err(err) => return Err(err), }; match tokio::fs::remove_dir_all(&disk).await { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => { // Idempotent by contract (crabidy.proto). } Err(err) => { warn!(path, "cannot delete folder: {err}"); return Err(ProviderError::InternalError); } } } let parent = crabidy_core::parent_path(path) .map(String::from) .unwrap_or_else(|| self.provider_root.clone()); self.get_lib_node(&parent).await } // `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"; // `skipped = false` counts as unset; combined with another field it // is fine, alone it sets nothing. let false_only = "title = \"x\"\n[playable]\nskipped = false\n"; let skipped_and_url = "title = \"x\"\n[playable]\nurl = \"https://a\"\nskipped = true\n"; for bad in [none, both, false_only, skipped_and_url] { 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 skipped_playable_parses_marks_and_round_trips() { let file = TrackFile::parse("title = \"gone\"\n[playable]\nskipped = true\n") .expect("skipped parses"); assert_eq!(file.playable().expect("playable"), Playable::Skipped); // The wire track keeps the lib path and carries the flag. let lib_path = "/captures/mix/0001 gone.cbd-track.toml"; let track = file.to_track(lib_path); assert_eq!(track.path, lib_path); assert!(track.is_skipped); // Persistence keeps skipped-ness: a skipped wire track serializes // back to a skipped playable, not a dead link. let persisted = TrackFile::from_track(&track); assert_eq!(persisted.playable().expect("playable"), Playable::Skipped); let text = persisted.to_toml().expect("serialize"); let reparsed = TrackFile::parse(&text).expect("round trip"); assert!(reparsed.to_track(lib_path).is_skipped); } #[test] fn from_track_skipped_overrides_any_source_playable() { // Download captures record an uncapturable track as skipped even // though the source track itself was a normal (e.g. tidal) track. let stream = TrackFile::parse(&url_track("t")).expect("valid"); let track = stream.to_track("/fs/t.cbd-track.toml"); assert!(!track.is_skipped); let skipped = TrackFile::from_track_skipped(&track); assert_eq!(skipped.playable().expect("playable"), Playable::Skipped); assert_eq!(skipped.title, "t"); } #[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\"\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 links_into_fs_instances_are_legal_and_one_hop() { // Persisted queues link to whatever the queue held — including // `/fs/...` tracks — so links into fs-provider instances must parse // (architecture/queue-persistence.md D2). Safety comes from links // being one hop by construction: `get_urls_for_track` never follows // a link (covered by `urls_resolve_per_playable_kind`), so chains // die at play time and cycles cannot recurse. let file = TrackFile::parse("title = \"t\"\n[playable]\nlink = \"/fs/a.cbd-track.toml\"\n") .expect("link into /fs parses"); assert_eq!( file.playable().expect("playable"), Playable::Link("/fs/a.cbd-track.toml".into()) ); assert_eq!( file.to_track("/queues/mix/0001 t.cbd-track.toml").path, "/fs/a.cbd-track.toml" ); } #[test] fn to_track_rewrites_the_path_only_for_links() { let lib_path = "/fs/mix/song.cbd-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.cbd-track.toml")); assert!(!client.is_track_path("/fs/mix")); assert!(!client.is_track_path("/tidal/song.cbd-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.cbd-track.toml", &url_track("b")); write_track(dir.path(), "A song.cbd-track.toml", &url_track("a")); write_track(dir.path(), "broken.cbd-track.toml", "not [ valid"); write_track(dir.path(), ".hidden.cbd-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.cbd-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.cbd-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.cbd-track.toml", "title = \"r\"\n[playable]\nfile = \"a.flac\"\n", ); let abs_target = dir.path().join("elsewhere.mp3"); write_track( &sub, "abs.cbd-track.toml", &format!( "title = \"a\"\n[playable]\nfile = {:?}\n", abs_target.to_str().expect("utf8") ), ); write_track(&sub, "web.cbd-track.toml", &url_track("w")); write_track( &sub, "linked.cbd-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.cbd-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.cbd-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.cbd-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.cbd-track.toml") .await .expect_err("link"); assert_eq!(err, ProviderError::MalformedPath); // Skipped playables have no audio: a typed error, not empty success // (playback normally never asks — it skips on the wire flag). write_track( &sub, "gone.cbd-track.toml", "title = \"g\"\n[playable]\nskipped = true\n", ); let err = client .get_urls_for_track("/fs/mix/gone.cbd-track.toml") .await .expect_err("skipped"); assert_eq!(err, ProviderError::FetchError); } #[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.cbd-track.toml", "title = \"t\"\nartist = \"a\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n", ); let track = client .get_metadata_for_track("/fs/linked.cbd-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.cbd-track.toml", &url_track("one")); write_track(&al1, "02.cbd-track.toml", &url_track("two")); write_track(&al2, "01.cbd-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); } // ---- multiple instances (queue persistence) -------------------------- #[tokio::test] async fn instances_serve_their_own_provider_root() { let dir = TempDir::new().expect("tempdir"); fs::create_dir(dir.path().join("road trip")).expect("mkdir"); write_track( &dir.path().join("road trip"), "0001 one.cbd-track.toml", "title = \"one\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n", ); let client = Client::new("/queues", dir.path().to_path_buf()).expect("instance"); assert_eq!(client.provider_root(), "/queues"); // The whole path scheme follows the instance root... let root = client.get_lib_node("/queues").await.expect("root"); assert_eq!(root.title, "queues"); assert_eq!(root.parent.as_deref(), Some(crabidy_core::ROOT_PATH)); assert_eq!(root.children[0].path, "/queues/road%20trip"); let node = client .get_lib_node("/queues/road%20trip") .await .expect("queue folder"); assert_eq!(node.parent.as_deref(), Some("/queues")); assert_eq!(node.tracks[0].path, "/tidal/artists/1/2"); assert!( client.is_track_path("/queues/road%20trip/0001%20one.cbd-track.toml"), "track paths under the instance root" ); // ... and paths of other instances are foreign to this one. assert!(!client.is_track_path("/fs/x.cbd-track.toml")); assert!(client.get_lib_node("/fs").await.is_err()); let lib_root = ProviderClient::get_lib_root(&client); assert_eq!(lib_root.path, "/queues"); assert_eq!(lib_root.title, "queues"); } #[tokio::test] async fn instance_paths_cannot_escape_their_root_either() { let dir = TempDir::new().expect("tempdir"); let client = Client::new("/queues", dir.path().to_path_buf()).expect("instance"); for evil in ["/queues/..", "/queues/%2E%2E", "/queues//x"] { let err = client.get_lib_node(evil).await.expect_err(evil); assert_eq!(err, ProviderError::MalformedPath, "{evil}"); } } #[test] fn new_rejects_malformed_roots() { let dir = TempDir::new().expect("tempdir"); let disk = dir.path().to_path_buf(); for bad_provider_root in ["queues", "/", "/a/b", "/queues/", ""] { assert!( matches!( Client::new(bad_provider_root, disk.clone()), Err(ProviderError::Config(_)) ), "provider root {bad_provider_root:?} must be rejected" ); } assert!( matches!( Client::new("/queues", PathBuf::from("relative/dir")), Err(ProviderError::Config(_)) ), "relative disk roots must be rejected" ); } // ---- serialization (queue persistence) ------------------------------- #[test] fn from_track_round_trips_through_a_link_file() { let track = Track { path: "/fs/mix/song.cbd-track.toml".to_string(), artist: "Queen".to_string(), title: "We Will Rock You".to_string(), duration: Some(122), album: Some(Album { title: "News of the World".to_string(), release_date: Some("1977-10-28".to_string()), }), is_skipped: false, provider_item_id: String::new(), is_captured: false, }; let toml_text = TrackFile::from_track(&track).to_toml().expect("serialize"); let reparsed = TrackFile::parse(&toml_text).expect("reparse"); assert_eq!( reparsed.playable().expect("playable"), Playable::Link("/fs/mix/song.cbd-track.toml".into()) ); // The listing rewrite turns the link back into the original track, // wherever the file lives. let restored = reparsed.to_track("/queues/current/0001 We Will Rock You.cbd-track.toml"); assert_eq!(restored, track); } #[test] fn link_files_preserve_the_provider_item_id() { // A streamed track (e.g. Tidal) carries a provider id; a link save // (queue/bookmark) must keep it so a later capture of that save can // de-duplicate by id before downloading (crabidy-store.md D4). let track = Track { path: "/tidal/albums/1/125169484".to_string(), artist: "Efence".to_string(), title: "Bay of Lost Dreams".to_string(), duration: Some(216), album: None, is_skipped: false, provider_item_id: "125169484".to_string(), is_captured: false, }; let toml_text = TrackFile::from_track(&track).to_toml().expect("serialize"); assert!( toml_text.contains("provider_item_id = \"125169484\""), "the id is written to the link toml: {toml_text}" ); let restored = TrackFile::parse(&toml_text) .expect("reparse") .to_track("/crabidy/current/0001 Bay of Lost Dreams.cbd-track.toml"); assert_eq!(restored.provider_item_id, "125169484"); assert_eq!(restored, track); } #[tokio::test] async fn from_track_with_file_plays_the_relative_sibling() { let track = Track { path: "/tidal/artists/1/2/3".to_string(), artist: "Queen".to_string(), title: "One".to_string(), duration: Some(90), album: Some(Album { title: "Greatest".to_string(), release_date: None, }), is_skipped: false, provider_item_id: String::new(), is_captured: false, }; let file = TrackFile::from_track_with_file(&track, Path::new("0001 One.flac")); let toml_text = file.to_toml().expect("serialize"); let reparsed = TrackFile::parse(&toml_text).expect("reparse"); assert_eq!( reparsed.playable().expect("playable"), Playable::File("0001 One.flac".into()) ); // File playables keep the *library* path (no link rewrite), and the // metadata survives the round trip. let lib_path = "/captures/faves/0001%20One.cbd-track.toml"; let listed = reparsed.to_track(lib_path); assert_eq!(listed.path, lib_path); assert_eq!(listed.title, track.title); assert_eq!(listed.album, track.album); // End to end: a capture folder replays through an instance and the // audio resolves to the absolute sibling path. let dir = TempDir::new().expect("tempdir"); let faves = dir.path().join("faves"); fs::create_dir_all(&faves).expect("mkdir"); fs::write(faves.join("0001 One.flac"), b"flac").expect("audio"); fs::write(faves.join(track_file_name(0, "One")), toml_text).expect("toml"); let client = Client::new("/captures", dir.path().to_path_buf()).expect("instance"); let urls = client .get_urls_for_track("/captures/faves/0001%20One.cbd-track.toml") .await .expect("resolve"); assert_eq!( urls, vec![faves.join("0001 One.flac").display().to_string()] ); } #[test] fn from_track_serializes_sparse_metadata() { // No artist/duration/album: serialization must not fail on `None` // (TOML has no null) and the round trip stays lossless. let track = Track { path: "/tidal/artists/1/2".to_string(), artist: String::new(), title: "stream".to_string(), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }; let toml_text = TrackFile::from_track(&track).to_toml().expect("serialize"); let reparsed = TrackFile::parse(&toml_text).expect("reparse"); assert_eq!(reparsed.to_track("/queues/x.cbd-track.toml"), track); } #[tokio::test] async fn downloadable_instances_flag_every_node() { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("mix")).expect("mkdir"); // Off by default: /fs and /captures stay uncapturable. let plain = Client::new("/fs", dir.path().to_path_buf()).expect("instance"); let node = plain.get_lib_node("/fs").await.expect("list"); assert!(!node.is_downloadable); assert!(!node.children[0].is_downloadable); // Opted in (/queues, /bookmarks): nodes and children advertise W. let blessed = Client::new("/queues", dir.path().to_path_buf()) .expect("instance") .with_downloadable_nodes(); let node = blessed.get_lib_node("/queues").await.expect("list"); assert!(node.is_downloadable); assert!(node.children[0].is_downloadable); let nested = blessed.get_lib_node("/queues/mix").await.expect("list"); assert!(nested.is_downloadable); } // ---- mutable top level (bookmarks / saved queues) --------------------- /// An editable instance over a root with two folders (one reserved) and /// a nested subfolder. async fn editable_client() -> (Client, TempDir) { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("current")).expect("mkdir"); fs::create_dir_all(dir.path().join("road trip/album")).expect("mkdir"); let client = Client::new("/queues", dir.path().to_path_buf()) .expect("instance") .with_editable_top_level(&["current"]); (client, dir) } #[tokio::test] async fn editable_instances_flag_only_unreserved_top_level_folders() { let (client, _dir) = editable_client().await; let root = client.get_lib_node("/queues").await.expect("root"); let current = &root.children[0]; let saved = &root.children[1]; assert_eq!(current.title, "current"); assert!(!current.is_editable && !current.is_deletable, "reserved"); assert_eq!(saved.title, "road trip"); assert!(saved.is_editable && saved.is_deletable); // Deeper levels stay immutable. let nested = client .get_lib_node("/queues/road%20trip") .await .expect("nested listing"); assert!(nested .children .iter() .all(|c| !c.is_editable && !c.is_deletable)); // Instances without the option (e.g. /fs) never set the flags. let (immutable, _dir2) = client_with_root().await; fs::create_dir(_dir2.path().join("music")).expect("mkdir"); let root = immutable.get_lib_node("/fs").await.expect("fs root"); assert!(root .children .iter() .all(|c| !c.is_editable && !c.is_deletable)); } #[tokio::test] async fn rename_moves_a_top_level_folder() { let (client, dir) = editable_client().await; let node = client .rename_lib_node("/queues/road%20trip", "hiking") .await .expect("rename"); assert_eq!(node.path, "/queues/hiking"); assert_eq!(node.title, "hiking"); assert!(dir.path().join("hiking/album").is_dir(), "content moved"); assert!(!dir.path().join("road trip").exists()); } #[tokio::test] async fn rename_rejects_reserved_invalid_and_colliding_targets() { let (client, _dir) = editable_client().await; // The reserved folder is not editable. let err = client .rename_lib_node("/queues/current", "x") .await .expect_err("reserved"); assert_eq!(err, ProviderError::NotSupported); // Nested folders are not editable. let err = client .rename_lib_node("/queues/road%20trip/album", "x") .await .expect_err("nested"); assert_eq!(err, ProviderError::NotSupported); // Bad new titles are invalid input: empty, separators, hidden, // reserved target. for bad in ["", " ", "a/b", ".hidden", "current"] { let err = client .rename_lib_node("/queues/road%20trip", bad) .await .expect_err(bad); assert_eq!(err, ProviderError::InvalidInput, "{bad:?}"); } // Renaming onto an existing sibling never merges. fs::create_dir(_dir.path().join("taken")).expect("mkdir"); let err = client .rename_lib_node("/queues/road%20trip", "taken") .await .expect_err("collision"); assert_eq!(err, ProviderError::InvalidInput); // Immutable instances keep the old contract. let (immutable, _dir2) = client_with_root().await; fs::create_dir(_dir2.path().join("music")).expect("mkdir"); let err = immutable .rename_lib_node("/fs/music", "x") .await .expect_err("immutable instance"); assert_eq!(err, ProviderError::NotSupported); } #[tokio::test] async fn delete_removes_top_level_folders_idempotently() { let (client, dir) = editable_client().await; let parent = client .delete_lib_node("/queues/road%20trip") .await .expect("delete"); assert_eq!(parent.path, "/queues"); assert!(!dir.path().join("road trip").exists()); assert_eq!(parent.children.len(), 1, "only `current` remains"); // Idempotent: deleting an already-gone folder succeeds. client .delete_lib_node("/queues/road%20trip") .await .expect("idempotent delete"); // Reserved and nested folders are not deletable. for undeletable in ["/queues/current", "/queues/current/nested"] { let err = client .delete_lib_node(undeletable) .await .expect_err(undeletable); assert_eq!(err, ProviderError::NotSupported, "{undeletable}"); } // Neither are track files: only deletable trees delete tracks. fs::create_dir(dir.path().join("hiking")).expect("mkdir"); write_track( &dir.path().join("hiking"), "0001 a.cbd-track.toml", &url_track("a"), ); let err = client .delete_lib_node("/queues/hiking/0001%20a.cbd-track.toml") .await .expect_err("track"); assert_eq!(err, ProviderError::NotSupported); } /// A captures-shaped instance: editable top level plus a deletable /// tree, with one nested album holding a downloaded track (audio next /// to its toml) and a URL track. async fn deletable_tree_client() -> (Client, TempDir) { let dir = TempDir::new().expect("tempdir"); let album = dir.path().join("mix/album"); fs::create_dir_all(&album).expect("mkdir"); write_track( &album, "0001 song.cbd-track.toml", "title = \"song\"\n[playable]\nfile = \"0001 song.m4a\"\n", ); fs::write(album.join("0001 song.m4a"), b"audio").expect("write audio"); write_track(&album, "0002 radio.cbd-track.toml", &url_track("radio")); let client = Client::new("/captures", dir.path().to_path_buf()) .expect("instance") .with_editable_top_level(&[]) .with_deletable_tree(); (client, dir) } #[tokio::test] async fn deletable_trees_flag_every_folder_and_track() { let (client, _dir) = deletable_tree_client().await; let root = client.get_lib_node("/captures").await.expect("root"); assert!(root.tracks_deletable); let top = &root.children[0]; assert!( top.is_editable && top.is_deletable, "top level: rename + delete" ); let nested = client .get_lib_node("/captures/mix") .await .expect("nested listing"); assert!(nested.tracks_deletable); let album = &nested.children[0]; assert!(album.is_deletable, "nested folders are deletable"); assert!(!album.is_editable, "but not renamable"); // Editable-only instances (queues, bookmarks) keep tracks and // nested folders immutable. let (queues, _dir2) = editable_client().await; let root = queues.get_lib_node("/queues").await.expect("root"); assert!(!root.tracks_deletable); } #[tokio::test] async fn deletable_trees_delete_nested_folders_recursively() { let (client, dir) = deletable_tree_client().await; let parent = client .delete_lib_node("/captures/mix/album") .await .expect("delete"); assert_eq!(parent.path, "/captures/mix", "refreshed parent listing"); assert!(parent.children.is_empty()); assert!(!dir.path().join("mix/album").exists(), "gone from disk"); // Idempotent, like top-level deletes. client .delete_lib_node("/captures/mix/album") .await .expect("idempotent delete"); // The instance root itself stays undeletable. let err = client.delete_lib_node("/captures").await.expect_err("root"); assert_eq!(err, ProviderError::NotSupported); } #[tokio::test] async fn deleting_a_track_removes_its_file_and_local_audio() { let (client, dir) = deletable_tree_client().await; let album = dir.path().join("mix/album"); let parent = client .delete_lib_node("/captures/mix/album/0001%20song.cbd-track.toml") .await .expect("delete track"); assert_eq!(parent.path, "/captures/mix/album"); assert_eq!(parent.tracks.len(), 1, "only the radio track remains"); assert!(!album.join("0001 song.cbd-track.toml").exists()); assert!(!album.join("0001 song.m4a").exists(), "audio deleted too"); // Idempotent: the track is already gone. client .delete_lib_node("/captures/mix/album/0001%20song.cbd-track.toml") .await .expect("idempotent delete"); // URL tracks have no local audio; only the toml goes. client .delete_lib_node("/captures/mix/album/0002%20radio.cbd-track.toml") .await .expect("delete url track"); assert!(!album.join("0002 radio.cbd-track.toml").exists()); } #[tokio::test] async fn deleting_a_track_never_touches_audio_outside_the_root() { let (client, dir) = deletable_tree_client().await; let outside = TempDir::new().expect("outside dir"); let audio = outside.path().join("keep.flac"); fs::write(&audio, b"audio").expect("write audio"); let album = dir.path().join("mix/album"); write_track( &album, "0003 external.cbd-track.toml", &format!( "title = \"external\"\n[playable]\nfile = {:?}\n", audio.to_str().unwrap() ), ); client .delete_lib_node("/captures/mix/album/0003%20external.cbd-track.toml") .await .expect("delete track"); assert!(!album.join("0003 external.cbd-track.toml").exists()); assert!(audio.exists(), "external audio is not ours to delete"); } // ---- shared naming ---------------------------------------------------- #[test] fn dir_names_share_the_track_file_sanitizer() { assert_eq!(dir_name(0, "News of the World"), "0001 News of the World"); assert_eq!(dir_name(10, "x"), "0011 x"); let tricky = dir_name(1, "../a/b\\c\0"); assert!(!tricky.contains(['/', '\\', '\0']), "{tricky}"); assert!(!tricky.starts_with('.'), "{tricky}"); assert_eq!(dir_name(2, ""), "0003 track"); } #[test] fn folder_name_validation_trims_and_rejects() { assert_eq!( validate_folder_name(" hiking ", &["current"]), Ok("hiking") ); for bad in ["", " ", "a/b", "a\\b", "a\0b", ".hidden", "current"] { assert!( validate_folder_name(bad, &["current"]).is_err(), "{bad:?} must be rejected" ); } // Reservation applies to the trimmed form. assert!(validate_folder_name(" current ", &["current"]).is_err()); } #[test] fn track_file_names_sort_in_queue_order_and_stay_plain() { // Zero-padded one-based prefix, suffix appended. assert_eq!( track_file_name(0, "Bohemian Rhapsody"), "0001 Bohemian Rhapsody.cbd-track.toml" ); assert_eq!(track_file_name(9, "x"), "0010 x.cbd-track.toml"); // Sanitized: no separators/NUL (they would corrupt the folder), no // leading dot (hidden files are invisible to listings), never empty. let tricky = track_file_name(1, "../a/b\\c\0"); assert!(!tricky.contains(['/', '\\', '\0']), "{tricky}"); assert!(!tricky.starts_with('.'), "{tricky}"); assert_eq!(track_file_name(2, ""), "0003 track.cbd-track.toml"); assert_eq!(track_file_name(3, "..."), "0004 track.cbd-track.toml"); // The case-insensitive listing sort reproduces queue order. let names: Vec = (0..12).map(|i| track_file_name(i, "Song")).collect(); let mut sorted = names.clone(); sorted.sort_by_key(|n| n.to_lowercase()); assert_eq!(names, sorted); } }