From 1b838578e2bd83adcaa036e698dbb4a8220295b9 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 25 Jul 2026 02:59:38 +0200 Subject: [PATCH] server: dispatch providers through a mount registry The orchestrator held nine Option> fields and repeated the same if-owns-this-path chain across eight ProviderClient methods. ProviderClient is dyn-compatible (init carries Self: Sized), so mounts are now Arc in one Vec, and each method is a single owner lookup. Behaviour is unchanged: same owner boundaries (/fsx is still not /fs), same MalformedPath for lookups and NotSupported for mutations, same root ordering (crabidy first, orphans last, rest alphabetical) now done once at build time, same annotate_captured on get_lib_node. The five config-file providers that only differ in their file and root share one mount_from_config helper. This is the groundwork for putting each provider behind a build feature (architecture/build-features.md): a provider is now named in exactly one place. Co-Authored-By: Claude Opus 5 (1M context) --- crabidy-server/src/provider.rs | 1219 +++++++++++++------------------- 1 file changed, 501 insertions(+), 718 deletions(-) diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 46139c6..553ee58 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -10,116 +10,135 @@ use crabidy_core::{ use std::{fs, path::PathBuf, sync::Arc}; use tracing::{debug, debug_span, error, instrument, warn, Instrument}; +/// One mounted provider: the library root it owns, the name the root listing +/// shows, and the client every path beneath that root is dispatched to. +/// +/// Mounts are the *only* place a provider is named at runtime, so adding or +/// removing one (including behind a build feature, +/// architecture/build-features.md D9) touches one registration and nothing +/// else. +struct Mount { + /// The provider's library root, e.g. `/tidal`. No trailing slash. + root: &'static str, + /// The title of this provider's child in the library root listing. + name: &'static str, + client: Arc, +} + +impl std::fmt::Debug for Mount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Mount") + .field("root", &self.root) + .field("name", &self.name) + .finish_non_exhaustive() + } +} + +impl Mount { + fn new(root: &'static str, name: &'static str, client: Arc) -> Self { + Self { root, name, client } + } + + /// Whether this mount owns `path`: its root exactly, or anything below it. + /// The `/` boundary matters — `/fsx` is a different provider from `/fs`. + fn owns(&self, path: &str) -> bool { + path == self.root + || (path.len() > self.root.len() + && path.starts_with(self.root) + && path.as_bytes()[self.root.len()] == b'/') + } +} + +/// Initializes a provider that keeps its settings in one config file under +/// `config_dir` and returns its mount — or `None`, with a warning, when it +/// cannot come up. Every provider using this is non-fatal by design: a +/// failure costs its subtree, never the server. +/// +/// `init` may resolve values of its own (a scraped SoundCloud `client_id`, a +/// refreshed token), so whatever it reports is written back. +async fn mount_from_config( + config_dir: &std::path::Path, + config_file: &str, + root: &'static str, + name: &'static str, +) -> Option +where + C: ProviderClient + 'static, +{ + let file = config_dir.join(config_file); + debug!(config_file = %file.display(), provider = name, "loading provider config"); + let raw_settings = fs::read_to_string(&file).unwrap_or_default(); + match C::init(&raw_settings).await { + Ok(client) => { + if let Err(err) = tokio::fs::write(&file, client.settings()).await { + error!("failed to write {name} config file: {err}"); + } + Some(Mount::new(root, name, Arc::new(client))) + } + Err(err) => { + warn!("{name} provider disabled: {err}"); + None + } + } +} + +/// Sort key for the library root listing: `crabidy` first, `orphans` last, +/// everything else alphabetically between them. +fn listing_rank(name: &str) -> (u8, &str) { + match name { + "crabidy" => (0, name), + "orphans" => (2, name), + _ => (1, name), + } +} + #[derive(Debug)] pub struct ProviderOrchestrator { pub provider_tx: flume::Sender, provider_rx: flume::Receiver, - /// The Tidal provider; `None` when disabled in `crabidy-server.toml` (or, - /// unlike the other providers, when its config fails to load — that stays - /// fatal at startup, see [`ProviderOrchestrator::build`]). - tidal_client: Option>, - /// `None` when the filesystem provider failed to initialize — the - /// server runs without `/fs` instead of dying (architecture D5). - fs_client: Option>, - /// Second `fsdy` instance mounting the `/crabidy` toml tree - /// (architecture/crabidy-store.md D1) — saved queues, bookmarks, and - /// captures collapsed into one provider. `None` without a state - /// directory — the server then runs without `/crabidy`. - crabidy_client: Option>, + /// Every provider this server mounted, in root-listing order. A provider + /// that is disabled in `crabidy-server.toml`, or that failed to + /// initialize, is simply absent — its paths then have no owner. + mounts: Vec, /// The single writer behind `/crabidy`: the content store and the toml /// tree. `None` disables saving/capturing (and the `/crabidy` mount goes /// with it). crabidy_store: Option>, - /// The `/orphans` provider: the store's garbage-collection view - /// (architecture/orphans.md). `None` whenever `crabidy_store` is — it is a - /// view over the store. - orphans_client: Option>, - /// The YouTube provider (yt-dlp backed); `None` when the binary - /// probe failed at init (architecture/youtube-provider.md D2). - youtube_client: Option>, - /// The fyyd podcast provider; `None` when disabled or its client failed - /// to build (non-fatal, architecture/fyyd-provider.md D1). - fyyd_client: Option>, - /// The audiobookshelf provider; `None` when disabled or its config is - /// missing/incomplete — non-fatal, it only costs the `/abs` subtree - /// (architecture/audiobookshelf-provider.md D1). - abs_client: Option>, - /// The SoundCloud provider; `None` when disabled or no `client_id` could be - /// obtained — non-fatal, it only costs the `/soundcloud` subtree - /// (architecture/soundcloud-provider.md D1). - sc_client: Option>, - /// The Jamendo provider; `None` when disabled or no `client_id` is - /// configured — non-fatal, it only costs the `/jamendo` subtree - /// (architecture/jamendo-provider.md D1). - jamendo_client: Option>, -} - -/// Whether a path belongs to the filesystem provider. -fn fs_owns(path: &str) -> bool { - path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/") -} - -/// Whether a path belongs to the `/crabidy` provider instance (saved -/// queues, bookmarks, and captures). -fn crabidy_owns(path: &str) -> bool { - path == CRABIDY_PROVIDER_ROOT || path.starts_with("/crabidy/") -} - -/// Whether a path belongs to the YouTube provider. -fn youtube_owns(path: &str) -> bool { - path == ytdy::PROVIDER_ROOT || path.starts_with("/youtube/") -} - -/// Whether a path belongs to the `/orphans` provider. -fn orphans_owns(path: &str) -> bool { - path == ORPHANS_PROVIDER_ROOT || path.starts_with("/orphans/") -} - -/// Whether a path belongs to the fyyd podcast provider. -fn fyyd_owns(path: &str) -> bool { - path == fyyd::PROVIDER_ROOT || path.starts_with("/fyyd/") -} - -/// Whether a path belongs to the audiobookshelf provider. -fn abs_owns(path: &str) -> bool { - path == absdy::PROVIDER_ROOT || path.starts_with("/abs/") -} - -/// Whether a path belongs to the SoundCloud provider. -fn sc_owns(path: &str) -> bool { - path == soundclouddy::PROVIDER_ROOT || path.starts_with("/soundcloud/") -} - -/// Whether a path belongs to the Jamendo provider. -fn jamendo_owns(path: &str) -> bool { - path == jamendody::PROVIDER_ROOT || path.starts_with("/jamendo/") } impl ProviderOrchestrator { - /// The tidal client, or `MalformedPath` (with a warning) when the - /// provider is disabled — a `/tidal` path then has no owner. - fn tidal_provider(&self) -> Result<&tidaldy::Client, ProviderError> { - self.tidal_client.as_deref().ok_or_else(|| { - warn!("tidal provider is disabled"); - ProviderError::MalformedPath - }) + /// An orchestrator over exactly these mounts, ordered for the root + /// listing ([`listing_rank`]). No store: [`Self::build`] adds it when the + /// `/crabidy` provider comes up. + fn from_mounts(mut mounts: Vec) -> Self { + mounts.sort_by(|a, b| listing_rank(a.name).cmp(&listing_rank(b.name))); + let (provider_tx, provider_rx) = flume::bounded(100); + Self { + provider_tx, + provider_rx, + mounts, + crabidy_store: None, + } } - /// The fs client, or `MalformedPath` (with a warning) when the - /// provider is disabled — a `/fs` path then has no owner. - fn fs_provider(&self) -> Result<&fsdy::Client, ProviderError> { - self.fs_client.as_deref().ok_or_else(|| { - warn!("filesystem provider is disabled"); - ProviderError::MalformedPath - }) + /// The client that owns `path`, or `None` when no mounted provider does. + fn owner(&self, path: &str) -> Option<&dyn ProviderClient> { + self.mounts + .iter() + .find(|mount| mount.owns(path)) + .map(|mount| &*mount.client) } - /// The `/crabidy` client, or `MalformedPath` (with a warning) when the - /// instance is disabled — a `/crabidy` path then has no owner. - fn crabidy_provider(&self) -> Result<&fsdy::Client, ProviderError> { - self.crabidy_client.as_deref().ok_or_else(|| { - warn!("crabidy library is disabled"); - ProviderError::MalformedPath + /// Like [`Self::owner`], but turns "nobody owns this" into the typed error + /// the caller's operation uses, with one warning naming the path. + fn owner_or( + &self, + path: &str, + err: ProviderError, + ) -> Result<&dyn ProviderClient, ProviderError> { + self.owner(path).ok_or_else(|| { + warn!(path, "no mounted provider owns this path"); + err }) } @@ -128,59 +147,6 @@ impl ProviderOrchestrator { self.crabidy_store.clone() } - /// The `/orphans` client, or `MalformedPath` (with a warning) when the - /// store — and so the view — is disabled. - fn orphans_provider(&self) -> Result<&OrphansProvider, ProviderError> { - self.orphans_client.as_deref().ok_or_else(|| { - warn!("orphans provider is disabled"); - ProviderError::MalformedPath - }) - } - - /// The YouTube client, or `MalformedPath` (with a warning) when the - /// provider is disabled — a `/youtube` path then has no owner. - fn youtube_provider(&self) -> Result<&ytdy::Client, ProviderError> { - self.youtube_client.as_deref().ok_or_else(|| { - warn!("youtube provider is disabled"); - ProviderError::MalformedPath - }) - } - - /// The fyyd client, or `MalformedPath` (with a warning) when the - /// provider is disabled — a `/fyyd` path then has no owner. - fn fyyd_provider(&self) -> Result<&fyyd::Client, ProviderError> { - self.fyyd_client.as_deref().ok_or_else(|| { - warn!("fyyd provider is disabled"); - ProviderError::MalformedPath - }) - } - - /// The audiobookshelf client, or `MalformedPath` (with a warning) when the - /// provider is disabled — an `/abs` path then has no owner. - fn abs_provider(&self) -> Result<&absdy::Client, ProviderError> { - self.abs_client.as_deref().ok_or_else(|| { - warn!("abs provider is disabled"); - ProviderError::MalformedPath - }) - } - - /// The SoundCloud client, or `MalformedPath` (with a warning) when the - /// provider is disabled — a `/soundcloud` path then has no owner. - fn sc_provider(&self) -> Result<&soundclouddy::Client, ProviderError> { - self.sc_client.as_deref().ok_or_else(|| { - warn!("soundcloud provider is disabled"); - ProviderError::MalformedPath - }) - } - - /// The Jamendo client, or `MalformedPath` (with a warning) when the - /// provider is disabled — a `/jamendo` path then has no owner. - fn jamendo_provider(&self) -> Result<&jamendody::Client, ProviderError> { - self.jamendo_client.as_deref().ok_or_else(|| { - warn!("jamendo provider is disabled"); - ProviderError::MalformedPath - }) - } pub fn run(self) { tokio::spawn(async move { // Behind an Arc so long-running resolves can be spawned onto @@ -328,9 +294,12 @@ impl ProviderOrchestrator { .await .map_err(|e| ProviderError::Config(e.to_string()))?; } + // Everything that comes up gets pushed here; registration order does + // not matter, `from_mounts` sorts for the root listing. + let mut mounts: Vec = Vec::new(); // Tidal: skipped when disabled; when enabled a broken config is still // fatal (unlike the local providers), preserving prior behavior. - let tidal_client = if enabled.tidal { + if enabled.tidal { let config_file = config_dir.join("tidaly.toml"); debug!(config_file = %config_file.display(), "loading tidal config"); let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default(); @@ -343,12 +312,15 @@ impl ProviderOrchestrator { if let Err(err) = tokio::fs::write(&config_file, client.settings()).await { error!("failed to write tidal config file: {err}"); } - Some(Arc::new(client)) - } else { - None - }; + mounts.push(Mount::new( + tidaldy::PROVIDER_ROOT, + "tidal", + Arc::new(client), + )); + } // The filesystem provider is optional: a broken local config only - // costs the `/fs` subtree, never the server. + // costs the `/fs` subtree, never the server. Kept in a local as well + // as a mount: `/orphans` needs its disk root below. let fs_client = if enabled.fs { let fs_config_file = config_dir.join("fsdy.toml"); debug!(config_file = %fs_config_file.display(), "loading fs config"); @@ -368,6 +340,13 @@ impl ProviderOrchestrator { } else { None }; + if let Some(client) = &fs_client { + mounts.push(Mount::new( + fsdy::PROVIDER_ROOT, + "fs", + Arc::clone(client) as Arc, + )); + } // The single `/crabidy` provider: one content store + toml tree // (architecture/crabidy-store.md). The store owns both roots (state // tree + data store) and is the sole writer; the mounted `fsdy` @@ -398,164 +377,111 @@ impl ProviderOrchestrator { // downloadable (`W` captures a save) and deletable (deleting a toml // never touches the shared store, D7). Store playables resolve // against the data store root. - let crabidy_client = crabidy_store.as_ref().and_then(|store| { + if let Some(store) = &crabidy_store { match fsdy::Client::new(CRABIDY_PROVIDER_ROOT, store.tree_dir().to_path_buf()) { - Ok(client) => Some(Arc::new( - client - .with_editable_top_level(&[CURRENT_NAME]) - .with_downloadable_nodes() - .with_deletable_tree() - .with_store_root(store.store_dir().to_path_buf()), + Ok(client) => mounts.push(Mount::new( + CRABIDY_PROVIDER_ROOT, + "crabidy", + Arc::new( + client + .with_editable_top_level(&[CURRENT_NAME]) + .with_downloadable_nodes() + .with_deletable_tree() + .with_store_root(store.store_dir().to_path_buf()), + ), )), - Err(err) => { - warn!("crabidy library disabled: {err}"); - None - } + Err(err) => warn!("crabidy library disabled: {err}"), } - }); + } // `/orphans`: a view over the store, so it needs both its own toggle // and a live store. Its reference roots are the disk roots of the // mounted file providers — the `/crabidy` toml tree and (when enabled) // the `/fs` root — the only places a `Playable::Store` link can live // (architecture/orphans.md). - let orphans_client = if enabled.orphans { - crabidy_store.as_ref().map(|store| { + if enabled.orphans { + if let Some(store) = &crabidy_store { let mut ref_roots = vec![store.tree_dir().to_path_buf()]; if let Some(fs) = &fs_client { ref_roots.push(fs.disk_root().to_path_buf()); } - Arc::new(OrphansProvider::new(Arc::clone(store), ref_roots)) - }) - } else { - None - }; - // YouTube: non-fatal like the local providers — a missing or - // broken yt-dlp binary only costs the `/youtube` subtree. - let youtube_client = if enabled.youtube { - let yt_config_file = config_dir.join("ytdy.toml"); - debug!(config_file = %yt_config_file.display(), "loading youtube config"); - let raw_yt_settings = fs::read_to_string(&yt_config_file).unwrap_or_default(); - match ytdy::Client::init(&raw_yt_settings).await { - Ok(client) => { - if let Err(err) = tokio::fs::write(&yt_config_file, client.settings()).await { - error!("failed to write ytdy config file: {err}"); - } - Some(Arc::new(client)) - } - Err(err) => { - warn!("youtube provider disabled: {err}"); - None - } + mounts.push(Mount::new( + ORPHANS_PROVIDER_ROOT, + "orphans", + Arc::new(OrphansProvider::new(Arc::clone(store), ref_roots)), + )); } - } else { - None - }; - // fyyd: non-fatal like the other remote providers — a client that - // cannot be built only costs the `/fyyd` subtree. Needs no - // credentials, so a missing `fyyd.toml` is the normal case. - let fyyd_client = if enabled.fyyd { - let fyyd_config_file = config_dir.join("fyyd.toml"); - debug!(config_file = %fyyd_config_file.display(), "loading fyyd config"); - let raw_fyyd_settings = fs::read_to_string(&fyyd_config_file).unwrap_or_default(); - match fyyd::Client::init(&raw_fyyd_settings).await { - Ok(client) => { - if let Err(err) = tokio::fs::write(&fyyd_config_file, client.settings()).await { - error!("failed to write fyyd config file: {err}"); - } - Some(Arc::new(client)) - } - Err(err) => { - warn!("fyyd provider disabled: {err}"); - None - } - } - } else { - None - }; - // audiobookshelf: non-fatal like the other remote providers. Unlike - // fyyd it needs credentials, so a missing or incomplete `abs.toml` - // (no base_url/api_key) disables the provider — it only costs the - // `/abs` subtree (architecture/audiobookshelf-provider.md D1). - let abs_client = if enabled.abs { - let abs_config_file = config_dir.join("abs.toml"); - debug!(config_file = %abs_config_file.display(), "loading abs config"); - let raw_abs_settings = fs::read_to_string(&abs_config_file).unwrap_or_default(); - match absdy::Client::init(&raw_abs_settings).await { - Ok(client) => { - if let Err(err) = tokio::fs::write(&abs_config_file, client.settings()).await { - error!("failed to write abs config file: {err}"); - } - Some(Arc::new(client)) - } - Err(err) => { - warn!("abs provider disabled: {err}"); - None - } - } - } else { - None - }; - // SoundCloud: non-fatal. Works with no config (scrapes a `client_id`); - // a total failure to obtain one disables the `/soundcloud` subtree - // only (architecture/soundcloud-provider.md D1). `init` may write back - // a freshly scraped `client_id` so it persists. - let sc_client = if enabled.soundcloud { - let sc_config_file = config_dir.join("soundcloud.toml"); - debug!(config_file = %sc_config_file.display(), "loading soundcloud config"); - let raw_sc_settings = fs::read_to_string(&sc_config_file).unwrap_or_default(); - match soundclouddy::Client::init(&raw_sc_settings).await { - Ok(client) => { - if let Err(err) = tokio::fs::write(&sc_config_file, client.settings()).await { - error!("failed to write soundcloud config file: {err}"); - } - Some(Arc::new(client)) - } - Err(err) => { - warn!("soundcloud provider disabled: {err}"); - None - } - } - } else { - None - }; - // Jamendo: non-fatal. Needs a registered `client_id`; a missing or - // empty `jamendo.toml` disables the provider — it only costs the - // `/jamendo` subtree (architecture/jamendo-provider.md D1). - let jamendo_client = if enabled.jamendo { - let jamendo_config_file = config_dir.join("jamendo.toml"); - debug!(config_file = %jamendo_config_file.display(), "loading jamendo config"); - let raw_jamendo_settings = fs::read_to_string(&jamendo_config_file).unwrap_or_default(); - match jamendody::Client::init(&raw_jamendo_settings).await { - Ok(client) => { - if let Err(err) = - tokio::fs::write(&jamendo_config_file, client.settings()).await - { - error!("failed to write jamendo config file: {err}"); - } - Some(Arc::new(client)) - } - Err(err) => { - warn!("jamendo provider disabled: {err}"); - None - } - } - } else { - None - }; - let (provider_tx, provider_rx) = flume::bounded(100); + } + // The remaining providers all follow the same non-fatal shape — read + // the config file, init, write back what init resolved, mount on + // success — so they go through one helper: + // + // - YouTube: a missing or broken yt-dlp binary only costs `/youtube`. + // - fyyd: needs no credentials, so a missing `fyyd.toml` is normal. + // - audiobookshelf: an incomplete `abs.toml` (no base_url/api_key) + // disables it (architecture/audiobookshelf-provider.md D1). + // - SoundCloud: works with no config (it scrapes a `client_id`, which + // the write-back persists); only a total failure disables it + // (architecture/soundcloud-provider.md D1). + // - Jamendo: needs a registered `client_id`; without one it is + // disabled (architecture/jamendo-provider.md D1). + if enabled.youtube { + mounts.extend( + mount_from_config::( + &config_dir, + "ytdy.toml", + ytdy::PROVIDER_ROOT, + "youtube", + ) + .await, + ); + } + if enabled.fyyd { + mounts.extend( + mount_from_config::( + &config_dir, + "fyyd.toml", + fyyd::PROVIDER_ROOT, + "fyyd", + ) + .await, + ); + } + if enabled.abs { + mounts.extend( + mount_from_config::( + &config_dir, + "abs.toml", + absdy::PROVIDER_ROOT, + "abs", + ) + .await, + ); + } + if enabled.soundcloud { + mounts.extend( + mount_from_config::( + &config_dir, + "soundcloud.toml", + soundclouddy::PROVIDER_ROOT, + "soundcloud", + ) + .await, + ); + } + if enabled.jamendo { + mounts.extend( + mount_from_config::( + &config_dir, + "jamendo.toml", + jamendody::PROVIDER_ROOT, + "jamendo", + ) + .await, + ); + } Ok(Self { - provider_rx, - provider_tx, - tidal_client, - fs_client, - crabidy_client, crabidy_store, - orphans_client, - youtube_client, - fyyd_client, - abs_client, - sc_client, - jamendo_client, + ..Self::from_mounts(mounts) }) } } @@ -574,233 +500,38 @@ impl ProviderClient for ProviderOrchestrator { String::new() } - /// Routes to the provider that owns the path. + /// Routes to the provider that owns the path; an unowned path is not a + /// track. fn is_track_path(&self, path: &str) -> bool { - if path == "/tidal" || path.starts_with("/tidal/") { - return self - .tidal_client - .as_ref() - .is_some_and(|tidal| tidal.is_track_path(path)); - } - if fs_owns(path) { - return self - .fs_client - .as_ref() - .is_some_and(|fs| fs.is_track_path(path)); - } - if crabidy_owns(path) { - return self - .crabidy_client - .as_ref() - .is_some_and(|crabidy| crabidy.is_track_path(path)); - } - if youtube_owns(path) { - return self - .youtube_client - .as_ref() - .is_some_and(|youtube| youtube.is_track_path(path)); - } - if orphans_owns(path) { - // Orphans are addressed as nodes; there are no track paths. - return false; - } - if fyyd_owns(path) { - return self - .fyyd_client - .as_ref() - .is_some_and(|fyyd| fyyd.is_track_path(path)); - } - if abs_owns(path) { - return self - .abs_client - .as_ref() - .is_some_and(|abs| abs.is_track_path(path)); - } - if sc_owns(path) { - return self - .sc_client - .as_ref() - .is_some_and(|sc| sc.is_track_path(path)); - } - if jamendo_owns(path) { - return self - .jamendo_client - .as_ref() - .is_some_and(|jamendo| jamendo.is_track_path(path)); - } - false + self.owner(path) + .is_some_and(|provider| provider.is_track_path(path)) } #[instrument(skip(self))] async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { - if track_path.starts_with("/tidal/") { - return self.tidal_provider()?.get_urls_for_track(track_path).await; - } - if fs_owns(track_path) { - return self.fs_provider()?.get_urls_for_track(track_path).await; - } - if crabidy_owns(track_path) { - return self - .crabidy_provider()? - .get_urls_for_track(track_path) - .await; - } - if youtube_owns(track_path) { - return self - .youtube_provider()? - .get_urls_for_track(track_path) - .await; - } - if orphans_owns(track_path) { - return self - .orphans_provider()? - .get_urls_for_track(track_path) - .await; - } - if fyyd_owns(track_path) { - return self.fyyd_provider()?.get_urls_for_track(track_path).await; - } - if abs_owns(track_path) { - return self.abs_provider()?.get_urls_for_track(track_path).await; - } - if sc_owns(track_path) { - return self.sc_provider()?.get_urls_for_track(track_path).await; - } - if jamendo_owns(track_path) { - return self - .jamendo_provider()? - .get_urls_for_track(track_path) - .await; - } - warn!(path = track_path, "no provider owns this track path"); - Err(ProviderError::MalformedPath) + self.owner_or(track_path, ProviderError::MalformedPath)? + .get_urls_for_track(track_path) + .await } #[instrument(skip(self))] async fn get_metadata_for_track(&self, track_path: &str) -> Result { - if track_path.starts_with("/tidal/") { - return self - .tidal_provider()? - .get_metadata_for_track(track_path) - .await; - } - if fs_owns(track_path) { - return self.fs_provider()?.get_metadata_for_track(track_path).await; - } - if crabidy_owns(track_path) { - return self - .crabidy_provider()? - .get_metadata_for_track(track_path) - .await; - } - if youtube_owns(track_path) { - return self - .youtube_provider()? - .get_metadata_for_track(track_path) - .await; - } - if orphans_owns(track_path) { - return self - .orphans_provider()? - .get_metadata_for_track(track_path) - .await; - } - if fyyd_owns(track_path) { - return self - .fyyd_provider()? - .get_metadata_for_track(track_path) - .await; - } - if abs_owns(track_path) { - return self - .abs_provider()? - .get_metadata_for_track(track_path) - .await; - } - if sc_owns(track_path) { - return self.sc_provider()?.get_metadata_for_track(track_path).await; - } - if jamendo_owns(track_path) { - return self - .jamendo_provider()? - .get_metadata_for_track(track_path) - .await; - } - warn!(path = track_path, "no provider owns this track path"); - Err(ProviderError::MalformedPath) + self.owner_or(track_path, ProviderError::MalformedPath)? + .get_metadata_for_track(track_path) + .await } + /// The synthetic library root: one child per mounted provider, in mount + /// order (`crabidy` first, `orphans` last, the rest alphabetical — see + /// [`listing_rank`]). A provider that is disabled, failed to initialize, + /// or was built out is simply absent. fn get_lib_root(&self) -> LibraryNode { let mut root_node = LibraryNode::new(); - if self.tidal_client.is_some() { - let child = - LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false); - root_node.children.push(child); - } - if self.fs_client.is_some() { - let child = - LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false); - root_node.children.push(child); - } - if self.crabidy_client.is_some() { - let child = LibraryNodeChild::new( - CRABIDY_PROVIDER_ROOT.to_owned(), - "crabidy".to_owned(), - false, - ); - root_node.children.push(child); - } - if self.youtube_client.is_some() { - let child = - LibraryNodeChild::new(ytdy::PROVIDER_ROOT.to_owned(), "youtube".to_owned(), false); - root_node.children.push(child); - } - if self.orphans_client.is_some() { - let child = LibraryNodeChild::new( - ORPHANS_PROVIDER_ROOT.to_owned(), - "orphans".to_owned(), - false, - ); - root_node.children.push(child); - } - if self.fyyd_client.is_some() { - let child = - LibraryNodeChild::new(fyyd::PROVIDER_ROOT.to_owned(), "fyyd".to_owned(), false); - root_node.children.push(child); - } - if self.abs_client.is_some() { - let child = - LibraryNodeChild::new(absdy::PROVIDER_ROOT.to_owned(), "abs".to_owned(), false); - root_node.children.push(child); - } - if self.sc_client.is_some() { - let child = LibraryNodeChild::new( - soundclouddy::PROVIDER_ROOT.to_owned(), - "soundcloud".to_owned(), - false, - ); - root_node.children.push(child); - } - if self.jamendo_client.is_some() { - let child = LibraryNodeChild::new( - jamendody::PROVIDER_ROOT.to_owned(), - "jamendo".to_owned(), - false, - ); - root_node.children.push(child); - } - // Ordering: `crabidy` always first, `orphans` always last, everything - // else alphabetically by name. - root_node.children.sort_by(|a, b| { - fn rank(name: &str) -> (u8, &str) { - match name { - "crabidy" => (0, name), - "orphans" => (2, name), - _ => (1, name), - } - } - rank(&a.title).cmp(&rank(&b.title)) - }); + root_node.children = self + .mounts + .iter() + .map(|mount| LibraryNodeChild::new(mount.root.to_owned(), mount.name.to_owned(), false)) + .collect(); root_node } @@ -810,28 +541,10 @@ impl ProviderClient for ProviderOrchestrator { debug!("serving global library root"); return Ok(self.get_lib_root()); } - let mut node = if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - self.tidal_provider()?.get_lib_node(path).await? - } else if fs_owns(path) { - self.fs_provider()?.get_lib_node(path).await? - } else if crabidy_owns(path) { - self.crabidy_provider()?.get_lib_node(path).await? - } else if youtube_owns(path) { - self.youtube_provider()?.get_lib_node(path).await? - } else if orphans_owns(path) { - self.orphans_provider()?.get_lib_node(path).await? - } else if fyyd_owns(path) { - self.fyyd_provider()?.get_lib_node(path).await? - } else if abs_owns(path) { - self.abs_provider()?.get_lib_node(path).await? - } else if sc_owns(path) { - self.sc_provider()?.get_lib_node(path).await? - } else if jamendo_owns(path) { - self.jamendo_provider()?.get_lib_node(path).await? - } else { - warn!(path, "no provider owns this path"); - return Err(ProviderError::MalformedPath); - }; + let mut node = self + .owner_or(path, ProviderError::MalformedPath)? + .get_lib_node(path) + .await?; // Mark tracks already held in the content store (D3/D8) — cheap, and // works while browsing any provider, not just `/crabidy`. if let Some(store) = &self.crabidy_store { @@ -848,62 +561,9 @@ impl ProviderClient for ProviderOrchestrator { parent_path: &str, title: &str, ) -> Result { - if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") { - return self - .tidal_provider()? - .create_lib_node(parent_path, title) - .await; - } - if fs_owns(parent_path) { - return self - .fs_provider()? - .create_lib_node(parent_path, title) - .await; - } - if crabidy_owns(parent_path) { - return self - .crabidy_provider()? - .create_lib_node(parent_path, title) - .await; - } - if youtube_owns(parent_path) { - return self - .youtube_provider()? - .create_lib_node(parent_path, title) - .await; - } - if orphans_owns(parent_path) { - return self - .orphans_provider()? - .create_lib_node(parent_path, title) - .await; - } - if fyyd_owns(parent_path) { - return self - .fyyd_provider()? - .create_lib_node(parent_path, title) - .await; - } - if abs_owns(parent_path) { - return self - .abs_provider()? - .create_lib_node(parent_path, title) - .await; - } - if sc_owns(parent_path) { - return self - .sc_provider()? - .create_lib_node(parent_path, title) - .await; - } - if jamendo_owns(parent_path) { - return self - .jamendo_provider()? - .create_lib_node(parent_path, title) - .await; - } - warn!(parent_path, "no provider supports creating nodes here"); - Err(ProviderError::NotSupported) + self.owner_or(parent_path, ProviderError::NotSupported)? + .create_lib_node(parent_path, title) + .await } /// Routes to the provider that owns the path. The synthetic root's own @@ -914,50 +574,9 @@ impl ProviderClient for ProviderOrchestrator { path: &str, new_title: &str, ) -> Result { - if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - return self - .tidal_provider()? - .rename_lib_node(path, new_title) - .await; - } - if fs_owns(path) { - return self.fs_provider()?.rename_lib_node(path, new_title).await; - } - if crabidy_owns(path) { - return self - .crabidy_provider()? - .rename_lib_node(path, new_title) - .await; - } - if youtube_owns(path) { - return self - .youtube_provider()? - .rename_lib_node(path, new_title) - .await; - } - if orphans_owns(path) { - return self - .orphans_provider()? - .rename_lib_node(path, new_title) - .await; - } - if fyyd_owns(path) { - return self.fyyd_provider()?.rename_lib_node(path, new_title).await; - } - if abs_owns(path) { - return self.abs_provider()?.rename_lib_node(path, new_title).await; - } - if sc_owns(path) { - return self.sc_provider()?.rename_lib_node(path, new_title).await; - } - if jamendo_owns(path) { - return self - .jamendo_provider()? - .rename_lib_node(path, new_title) - .await; - } - warn!(path, "no provider supports renaming this node"); - Err(ProviderError::NotSupported) + self.owner_or(path, ProviderError::NotSupported)? + .rename_lib_node(path, new_title) + .await } /// Routes to the provider that owns the path. The synthetic root is not @@ -968,96 +587,260 @@ impl ProviderClient for ProviderOrchestrator { path: &str, chunk_tx: flume::Sender>, ) -> Result<(), ProviderError> { - if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - return self - .tidal_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if fs_owns(path) { - return self - .fs_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if crabidy_owns(path) { - return self - .crabidy_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if youtube_owns(path) { - return self - .youtube_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if orphans_owns(path) { - return self - .orphans_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if fyyd_owns(path) { - return self - .fyyd_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if abs_owns(path) { - return self - .abs_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if sc_owns(path) { - return self - .sc_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - if jamendo_owns(path) { - return self - .jamendo_provider()? - .resolve_tracks_into(path, chunk_tx) - .await; - } - warn!(path, "no provider owns this path"); - Err(ProviderError::MalformedPath) + self.owner_or(path, ProviderError::MalformedPath)? + .resolve_tracks_into(path, chunk_tx) + .await } /// Routes to the provider that owns the path. The synthetic root's own /// children are fixed and never deletable. #[instrument(skip(self))] async fn delete_lib_node(&self, path: &str) -> Result { - if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - return self.tidal_provider()?.delete_lib_node(path).await; - } - if fs_owns(path) { - return self.fs_provider()?.delete_lib_node(path).await; - } - if crabidy_owns(path) { - return self.crabidy_provider()?.delete_lib_node(path).await; - } - if youtube_owns(path) { - return self.youtube_provider()?.delete_lib_node(path).await; - } - if orphans_owns(path) { - return self.orphans_provider()?.delete_lib_node(path).await; - } - if fyyd_owns(path) { - return self.fyyd_provider()?.delete_lib_node(path).await; - } - if abs_owns(path) { - return self.abs_provider()?.delete_lib_node(path).await; - } - if sc_owns(path) { - return self.sc_provider()?.delete_lib_node(path).await; - } - if jamendo_owns(path) { - return self.jamendo_provider()?.delete_lib_node(path).await; - } - warn!(path, "no provider supports deleting this node"); - Err(ProviderError::NotSupported) + self.owner_or(path, ProviderError::NotSupported)? + .delete_lib_node(path) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A provider that records nothing but its own name, so a dispatch test + /// can assert *which* mount a path reached. + #[derive(Debug)] + struct FakeProvider { + root: &'static str, + /// Chunks its `resolve_tracks_into` override emits — proof the + /// override (not the trait default) is dispatched through `dyn`. + resolve_chunks: usize, + } + + impl FakeProvider { + fn new(root: &'static str) -> Arc { + Arc::new(Self { + root, + resolve_chunks: 1, + }) + } + + fn track(&self, path: &str) -> Track { + Track { + path: path.to_string(), + artist: self.root.to_string(), + title: "t".to_string(), + duration: None, + album: None, + is_skipped: false, + provider_item_id: String::new(), + is_captured: false, + } + } + + fn node(&self, path: &str) -> LibraryNode { + LibraryNode { + path: path.to_string(), + title: self.root.to_string(), + ..LibraryNode::new() + } + } + } + + #[async_trait] + impl ProviderClient for FakeProvider { + async fn init(_s: &str) -> Result { + Err(ProviderError::NotSupported) + } + fn settings(&self) -> String { + String::new() + } + fn is_track_path(&self, path: &str) -> bool { + path.ends_with("/track") + } + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { + Ok(vec![format!("{}:{track_path}", self.root)]) + } + async fn get_metadata_for_track(&self, track_path: &str) -> Result { + Ok(self.track(track_path)) + } + fn get_lib_root(&self) -> LibraryNode { + self.node(self.root) + } + async fn get_lib_node(&self, path: &str) -> Result { + Ok(self.node(path)) + } + async fn create_lib_node( + &self, + parent_path: &str, + _title: &str, + ) -> Result { + Ok(self.node(parent_path)) + } + async fn rename_lib_node( + &self, + path: &str, + _new_title: &str, + ) -> Result { + Ok(self.node(path)) + } + async fn delete_lib_node(&self, path: &str) -> Result { + Ok(self.node(path)) + } + /// Overrides the trait default; the test asserts this body runs. + async fn resolve_tracks_into( + &self, + path: &str, + chunk_tx: flume::Sender>, + ) -> Result<(), ProviderError> { + for _ in 0..self.resolve_chunks { + let _ = chunk_tx.send_async(vec![self.track(path)]).await; + } + Ok(()) + } + } + + fn orchestrator(roots: &[(&'static str, &'static str)]) -> ProviderOrchestrator { + let mounts = roots + .iter() + .map(|(root, name)| Mount::new(root, name, FakeProvider::new(root))) + .collect(); + ProviderOrchestrator::from_mounts(mounts) + } + + #[test] + fn mount_owns_its_root_and_children_only() { + let mount = Mount::new("/fs", "fs", FakeProvider::new("/fs")); + assert!(mount.owns("/fs")); + assert!(mount.owns("/fs/album")); + assert!(mount.owns("/fs/a/b/c")); + // A sibling whose name merely starts with the root is not ours. + assert!(!mount.owns("/fsx")); + assert!(!mount.owns("/fsx/album")); + assert!(!mount.owns("/")); + assert!(!mount.owns("")); + assert!(!mount.owns("fs")); + } + + #[tokio::test] + async fn dispatch_reaches_the_owning_mount() { + let orch = orchestrator(&[("/tidal", "tidal"), ("/fs", "fs")]); + // Every routed method must land on the mount that owns the path; the + // fake reports its own root so we can tell them apart. + let urls = orch.get_urls_for_track("/fs/x/track").await.expect("urls"); + assert_eq!(urls, vec!["/fs:/fs/x/track".to_string()]); + let track = orch + .get_metadata_for_track("/tidal/x/track") + .await + .expect("metadata"); + assert_eq!(track.artist, "/tidal"); + assert_eq!(orch.get_lib_node("/fs/a").await.expect("node").title, "/fs"); + assert_eq!( + orch.create_lib_node("/tidal/search", "q") + .await + .expect("create") + .title, + "/tidal" + ); + assert_eq!( + orch.rename_lib_node("/fs/a", "b") + .await + .expect("rename") + .title, + "/fs" + ); + assert_eq!( + orch.delete_lib_node("/fs/a").await.expect("delete").title, + "/fs" + ); + assert!(orch.is_track_path("/fs/a/track")); + assert!(!orch.is_track_path("/fs/a")); + } + + #[tokio::test] + async fn unowned_paths_are_typed_errors() { + let orch = orchestrator(&[("/fs", "fs")]); + // Lookups: MalformedPath. Mutations: NotSupported. Never a panic. + assert_eq!( + orch.get_urls_for_track("/nope/track").await, + Err(ProviderError::MalformedPath) + ); + assert_eq!( + orch.get_metadata_for_track("/nope/track").await, + Err(ProviderError::MalformedPath) + ); + assert_eq!( + orch.get_lib_node("/nope").await.err(), + Some(ProviderError::MalformedPath) + ); + assert_eq!( + orch.create_lib_node("/nope", "x").await.err(), + Some(ProviderError::NotSupported) + ); + assert_eq!( + orch.rename_lib_node("/nope", "x").await.err(), + Some(ProviderError::NotSupported) + ); + assert_eq!( + orch.delete_lib_node("/nope").await.err(), + Some(ProviderError::NotSupported) + ); + assert!(!orch.is_track_path("/nope/track")); + let (tx, _rx) = flume::bounded(1); + assert_eq!( + orch.resolve_tracks_into("/nope", tx).await, + Err(ProviderError::MalformedPath) + ); + } + + /// An empty build (`--no-default-features`) mounts nothing: the root is + /// empty and every path is unowned, but the server still answers. + #[tokio::test] + async fn no_mounts_serves_an_empty_root() { + let orch = orchestrator(&[]); + assert!(orch.get_lib_root().children.is_empty()); + assert_eq!( + orch.get_lib_node("/fs").await.err(), + Some(ProviderError::MalformedPath) + ); + // The synthetic root itself still resolves. + let root = orch + .get_lib_node(crabidy_core::ROOT_PATH) + .await + .expect("root"); + assert!(root.children.is_empty()); + } + + #[test] + fn root_lists_mounted_providers_in_order() { + // Registered in a deliberately wrong order: `crabidy` must come + // first, `orphans` last, everything else alphabetically. + let orch = orchestrator(&[ + ("/youtube", "youtube"), + ("/orphans", "orphans"), + ("/fs", "fs"), + ("/crabidy", "crabidy"), + ("/abs", "abs"), + ]); + let titles: Vec = orch + .get_lib_root() + .children + .into_iter() + .map(|child| child.title) + .collect(); + assert_eq!(titles, ["crabidy", "abs", "fs", "youtube", "orphans"]); + } + + #[tokio::test] + async fn overridden_resolve_tracks_into_is_dispatched() { + // The fake's override emits one chunk per call; the trait default + // would instead walk `get_lib_node` and emit none (no tracks). + let orch = orchestrator(&[("/fs", "fs")]); + let (tx, rx) = flume::bounded(4); + orch.resolve_tracks_into("/fs/album", tx) + .await + .expect("resolve"); + let chunks: Vec> = rx.drain().collect(); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0][0].artist, "/fs"); } }