1941 lines
80 KiB
Rust
1941 lines
80 KiB
Rust
//! 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<PathBuf>,
|
|
}
|
|
|
|
/// Errors from parsing or validating a serialized track file.
|
|
///
|
|
/// These never escape the crate as-is: at the [`ProviderClient`] boundary
|
|
/// they map to [`ProviderError`] (and inside directory listings a bad file
|
|
/// is skipped with a warning instead of failing the node).
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TrackFileError {
|
|
#[error("not valid TOML: {0}")]
|
|
Toml(#[from] toml::de::Error),
|
|
#[error("[playable] must set exactly one of `file`, `url`, `link`, `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<u32>,
|
|
/// Optional album metadata.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub album: Option<AlbumMeta>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<PathBuf>,
|
|
/// http(s) URL streamed by the player.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
/// 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<String>,
|
|
/// `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<bool>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<Self, TrackFileError> {
|
|
let file: TrackFile = toml::from_str(text)?;
|
|
// Validate eagerly so directory listings can skip a bad file on
|
|
// parse alone.
|
|
file.playable()?;
|
|
Ok(file)
|
|
}
|
|
|
|
/// The validated playable reference.
|
|
pub fn playable(&self) -> Result<Playable, TrackFileError> {
|
|
// `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(|_| "<not a url>".to_string());
|
|
if !matches!(scheme.as_str(), "http" | "https") {
|
|
// Only the scheme: the URL itself may embed a token.
|
|
return Err(TrackFileError::UrlScheme(scheme));
|
|
}
|
|
Ok(Playable::Url(url.clone()))
|
|
}
|
|
(None, None, Some(link), 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<String, TrackFileError> {
|
|
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<String>,
|
|
/// 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<PathBuf>,
|
|
}
|
|
|
|
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<Self, ProviderError> {
|
|
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<PathBuf, ProviderError> {
|
|
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<LibraryNode, ProviderError> {
|
|
let mut read_dir = tokio::fs::read_dir(disk_path).await.map_err(|err| {
|
|
warn!(path = lib_path, "cannot list directory: {err}");
|
|
ProviderError::MalformedPath
|
|
})?;
|
|
let mut dir_names: Vec<String> = Vec::new();
|
|
let mut track_names: Vec<String> = Vec::new();
|
|
loop {
|
|
let entry = match read_dir.next_entry().await {
|
|
Ok(Some(entry)) => entry,
|
|
Ok(None) => break,
|
|
Err(err) => {
|
|
warn!(path = lib_path, "error while listing directory: {err}");
|
|
break;
|
|
}
|
|
};
|
|
let Ok(file_type) = entry.file_type().await else {
|
|
continue;
|
|
};
|
|
// Symlinks are skipped by design: no cycles, no escaping the
|
|
// root (architecture D4).
|
|
if file_type.is_symlink() {
|
|
continue;
|
|
}
|
|
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
|
warn!(path = lib_path, "skipping entry with non-UTF-8 name");
|
|
continue;
|
|
};
|
|
if name.starts_with('.') {
|
|
continue;
|
|
}
|
|
if file_type.is_dir() {
|
|
dir_names.push(name);
|
|
} else if name.ends_with(TRACK_FILE_SUFFIX) {
|
|
track_names.push(name);
|
|
}
|
|
}
|
|
dir_names.sort_by_key(|n| n.to_lowercase());
|
|
track_names.sort_by_key(|n| n.to_lowercase());
|
|
|
|
let mut tracks = Vec::new();
|
|
for name in track_names {
|
|
let child_lib = crabidy_core::join_path(lib_path, &crabidy_core::encode_segment(&name));
|
|
// A bad file is skipped (read_track_file warned) and never
|
|
// poisons its siblings.
|
|
if let Ok(file) = self.read_track_file(&disk_path.join(&name)).await {
|
|
tracks.push(file.to_track(&child_lib));
|
|
}
|
|
}
|
|
// 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<TrackFile, ProviderError> {
|
|
let text = tokio::fs::read_to_string(disk_path).await.map_err(|err| {
|
|
warn!(file = %disk_path.display(), "cannot read track file: {err}");
|
|
ProviderError::MalformedPath
|
|
})?;
|
|
TrackFile::parse(&text).map_err(|err| {
|
|
warn!(file = %disk_path.display(), "skipping invalid track file: {err}");
|
|
ProviderError::InvalidInput
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProviderClient for Client {
|
|
/// Initializes from the raw `fsdy.toml` contents (may be empty).
|
|
///
|
|
/// The root defaults to `dirs::audio_dir()`; when neither is available
|
|
/// this is [`ProviderError::Config`]. A root that does not exist (yet)
|
|
/// is accepted — listing it just fails until it appears.
|
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
|
let settings: Settings = toml::from_str(raw_toml_settings)
|
|
.map_err(|err| ProviderError::Config(format!("invalid fsdy settings: {err}")))?;
|
|
let root = settings.root.or_else(dirs::audio_dir).ok_or_else(|| {
|
|
ProviderError::Config(
|
|
"no `root` configured and no platform music directory".to_string(),
|
|
)
|
|
})?;
|
|
// `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<Vec<String>, ProviderError> {
|
|
if !self.is_track_path(track_path) {
|
|
return Err(ProviderError::MalformedPath);
|
|
}
|
|
let disk = self.disk_path(track_path)?;
|
|
let file = self.read_track_file(&disk).await?;
|
|
// Parsing validated the playable; a failure here is a programmer
|
|
// error surfaced as a typed error, not a panic.
|
|
let playable = file.playable().map_err(|_| ProviderError::InternalError)?;
|
|
match playable {
|
|
Playable::File(target) => {
|
|
let absolute = if target.is_absolute() {
|
|
target
|
|
} else {
|
|
// Relative to the track file's directory (D3), so a
|
|
// music folder stays relocatable.
|
|
disk.parent().map(|dir| dir.join(&target)).unwrap_or(target)
|
|
};
|
|
let path = absolute
|
|
.to_str()
|
|
.ok_or(ProviderError::InternalError)?
|
|
.to_string();
|
|
Ok(vec![path])
|
|
}
|
|
Playable::Url(target) => Ok(vec![target]),
|
|
Playable::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<Track, ProviderError> {
|
|
if !self.is_track_path(track_path) {
|
|
return Err(ProviderError::MalformedPath);
|
|
}
|
|
let disk = self.disk_path(track_path)?;
|
|
let file = self.read_track_file(&disk).await?;
|
|
Ok(file.to_track(track_path))
|
|
}
|
|
|
|
/// A minimal 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<LibraryNode, ProviderError> {
|
|
if self.is_track_path(path) {
|
|
// Tracks are not nodes; the walk asks for metadata instead.
|
|
return Err(ProviderError::MalformedPath);
|
|
}
|
|
let disk = self.disk_path(path)?;
|
|
self.list_dir(path, &disk).await
|
|
}
|
|
|
|
/// Not supported — track files are created with normal file tools.
|
|
async fn create_lib_node(
|
|
&self,
|
|
_parent_path: &str,
|
|
_title: &str,
|
|
) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
|
|
/// 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<LibraryNode, ProviderError> {
|
|
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<LibraryNode, ProviderError> {
|
|
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<Vec<String>> = chunk_rx
|
|
.into_iter()
|
|
.map(|c| c.into_iter().map(|t| t.title).collect())
|
|
.collect();
|
|
assert_eq!(
|
|
chunks,
|
|
vec![
|
|
vec!["one".to_string(), "two".into()],
|
|
vec!["three".to_string()],
|
|
]
|
|
);
|
|
}
|
|
|
|
// ---- settings -------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn settings_round_trip_through_the_config_write_back() {
|
|
let (client, dir) = client_with_root().await;
|
|
let written = client.settings();
|
|
let parsed: Settings = toml::from_str(&written).expect("settings TOML");
|
|
assert_eq!(parsed.root.as_deref(), Some(dir.path()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn init_accepts_a_root_that_does_not_exist_yet() {
|
|
let settings = "root = \"/definitely/not/there\"\n";
|
|
let client = Client::init(settings).await.expect("init");
|
|
let err = client.get_lib_node("/fs").await.expect_err("listing fails");
|
|
assert_eq!(err, ProviderError::MalformedPath);
|
|
}
|
|
|
|
// ---- 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<String> = (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);
|
|
}
|
|
}
|