use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME}; use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT}; use crate::settings::ProviderToggles; use crate::{ProviderCommand, ProviderMessage}; use async_trait::async_trait; use crabidy_core::{ proto::crabidy::{LibraryNode, LibraryNodeChild, Track}, ProviderClient, ProviderError, }; use std::{fs, path::PathBuf, sync::Arc}; use tracing::{debug, debug_span, error, instrument, warn, Instrument}; #[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>, /// 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>, } /// 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/") } 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 }) } /// 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 `/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 }) } /// The `/crabidy` store (writer), for saves and the startup restore. pub fn crabidy_store(&self) -> Option> { 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 }) } pub fn run(self) { tokio::spawn(async move { // Behind an Arc so long-running resolves can be spawned onto // their own tasks while the loop keeps serving commands. let this = Arc::new(self); while let Ok(ProviderMessage { span, command }) = this.provider_rx.recv_async().await { let handler_span = debug_span!(parent: &span, "provider_command", command = command.name()); Arc::clone(&this) .handle_command(command) .instrument(handler_span) .await; } warn!("provider message channel closed, loop exiting"); }); } async fn handle_command(self: Arc, command: ProviderCommand) { match command { ProviderCommand::GetLibraryNode { path, result_tx } => { let result = self.get_lib_node(&path).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send get_library_node result: {err}"); } } ProviderCommand::GetTrackUrls { path, result_tx } => { let result = self.get_urls_for_track(&path).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send get_track_urls result: {err}"); } } ProviderCommand::ResolveTracks { path, chunk_tx } => { // Spawned: a large resolve must not block this loop, or the // playback side deadlocks waiting for `GetTrackUrls` while // chunks back up. Dropping `chunk_tx` at the end of the // task is the completion signal; there is no reply channel. let this = Arc::clone(&self); tokio::spawn( async move { if let Err(err) = this.resolve_tracks_into(&path, chunk_tx).await { warn!(path, "resolve produced no tracks: {err}"); } } .in_current_span(), ); } ProviderCommand::CreateLibraryNode { parent_path, title, result_tx, } => { let result = self.create_lib_node(&parent_path, &title).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send create_library_node result: {err}"); } } ProviderCommand::RenameLibraryNode { path, new_title, result_tx, } => { let result = self.rename_lib_node(&path, &new_title).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send rename_library_node result: {err}"); } } ProviderCommand::DeleteLibraryNode { path, result_tx } => { let result = self.delete_lib_node(&path).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send delete_library_node result: {err}"); } } ProviderCommand::CaptureLibraryNode { path, name, download, progress_tx, result_tx, } => { // Spawned: capturing a large artist walks many provider // nodes (and, for downloads, streams audio) and must not // block this loop (the walk itself calls back into // `get_lib_node` via `this`). Accept-then-stream // (architecture/incremental-captures.md D4): validation // answers the RPC, the walk reports through progress // events, ending in exactly one terminal event. let this = Arc::clone(&self); tokio::spawn( async move { let mode = if download { SaveMode::Capture } else { SaveMode::Link }; let accepted = match &this.crabidy_store { Some(store) => store.validate(&*this, &path, &name, mode).await, None => Err(crate::capture::CaptureError::Disabled), }; if let Err(err) = accepted { warn!(path, name, download, "capture rejected: {err}"); if let Err(err) = result_tx.send_async(Err(err)).await { error!("failed to send capture_library_node result: {err}"); } return; } if let Err(err) = result_tx.send_async(Ok(())).await { error!("failed to send capture_library_node result: {err}"); } let progress = crate::capture::Progress::new(&name, download, progress_tx); let result = match &this.crabidy_store { Some(store) => store.save(&*this, &path, &name, mode, &progress).await, None => Err(crate::capture::CaptureError::Disabled), }; if let Err(err) = &result { warn!(path, name, download, "cannot capture subtree: {err}"); } progress .finish(result.err().map(|err| err.to_string())) .await; } .in_current_span(), ); } } } } impl ProviderOrchestrator { /// Builds the orchestrator, mounting only the providers `enabled` turns on /// (from `crabidy-server.toml`'s `providers` list). Tidal, when enabled, /// still aborts startup on a broken config — its historical behavior — but /// a disabled tidal skips it entirely; the other providers are non-fatal /// and just drop their subtree. `/orphans` additionally needs the store, /// so it only mounts when `crabidy` does. #[instrument] pub async fn build(enabled: ProviderToggles) -> Result { let config_dir = dirs::config_dir() .map(|d| d.join("crabidy")) .unwrap_or(PathBuf::from("/tmp")); let dir_exists = tokio::fs::try_exists(&config_dir) .await .map_err(|e| ProviderError::Config(e.to_string()))?; if !dir_exists { tokio::fs::create_dir(&config_dir) .await .map_err(|e| ProviderError::Config(e.to_string()))?; } // 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 { 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(); let client = tidaldy::Client::init(&raw_toml_settings) .await .map_err(|err| { error!("failed to init tidal client: {err}"); err })?; 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 }; // The filesystem provider is optional: a broken local config only // costs the `/fs` subtree, never the server. 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"); let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default(); match fsdy::Client::init(&raw_fs_settings).await { Ok(client) => { if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await { error!("failed to write fsdy config file: {err}"); } Some(Arc::new(client)) } Err(err) => { warn!("filesystem provider disabled: {err}"); None } } } else { None }; // 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` // client reads the tree and resolves store playables. Non-fatal like // the other local providers — no state/data dir just drops `/crabidy`. let crabidy_store = if enabled.crabidy { match ( CrabidyStore::default_tree_root(), CrabidyStore::default_store_root(), ) { (Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await { Ok(store) => Some(Arc::new(store)), Err(err) => { warn!("crabidy library disabled: {err}"); None } }, _ => { warn!("crabidy library disabled: no state/data directory"); None } } } else { None }; // Saved queues/bookmarks are renamable and deletable; the // auto-persisted `current` stays untouchable. The whole tree is // 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| { 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()), )), Err(err) => { warn!("crabidy library disabled: {err}"); None } } }); // `/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| { 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 } } } else { None }; let (provider_tx, provider_rx) = flume::bounded(100); Ok(Self { provider_rx, provider_tx, tidal_client, fs_client, crabidy_client, crabidy_store, orphans_client, youtube_client, }) } } #[async_trait] impl ProviderClient for ProviderOrchestrator { /// Builds with every provider enabled. The server calls [`Self::build`] /// with the configured toggles instead; this exists only to satisfy the /// trait. #[instrument(skip(_s))] async fn init(_s: &str) -> Result { Self::build(ProviderToggles::all()).await } fn settings(&self) -> String { String::new() } /// Routes to the provider that owns the path. 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; } false } #[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; } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } #[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; } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } 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); } root_node } #[instrument(skip(self))] async fn get_lib_node(&self, path: &str) -> Result { if path == crabidy_core::ROOT_PATH { 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 { warn!(path, "no provider owns this path"); return Err(ProviderError::MalformedPath); }; // 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 { store.annotate_captured(&mut node).await; } Ok(node) } /// Routes to the provider that owns the parent path. The synthetic root /// itself is not creatable. #[instrument(skip(self))] async fn create_lib_node( &self, 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; } warn!(parent_path, "no provider supports creating nodes here"); Err(ProviderError::NotSupported) } /// Routes to the provider that owns the path. The synthetic root's own /// children are fixed and never editable. #[instrument(skip(self))] async fn rename_lib_node( &self, 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; } warn!(path, "no provider supports renaming this node"); Err(ProviderError::NotSupported) } /// Routes to the provider that owns the path. The synthetic root is not /// queueable, so only provider-owned paths can resolve. #[instrument(skip(self, chunk_tx))] async fn resolve_tracks_into( &self, 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; } warn!(path, "no provider owns this path"); Err(ProviderError::MalformedPath) } /// 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; } warn!(path, "no provider supports deleting this node"); Err(ProviderError::NotSupported) } }