diff --git a/README.md b/README.md index e20035c..7030d26 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ filled in. | `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | | `cbd-tui.toml` | `cbd-tui` | below | | `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) | -| `crabidy-server.toml`| server | below (never auto-created) | +| `crabidy-server.toml`| server | below (providers + auth) | The server-managed `crabidy` provider does not live under `~/.config`. Its track-file tree (saved queues, bookmarks, and captures) lives in @@ -105,7 +105,21 @@ cbd-tui auth owner 'my-password' # sets user + password cbd-tui auth queue-owner 'pw' --address http://pi:50051 ``` -### `crabidy-server.toml` — roles and rights +### `crabidy-server.toml` — providers and rights + +On first start the server writes this file with every provider enabled: + +```toml +providers = ["tidal", "youtube", "fs", "crabidy", "orphans"] +``` + +**Remove a name to disable that provider** — it no longer mounts and does +not appear in the library. Deleting the whole `providers` line re-enables +everything (a fresh install with no file behaves the same). Disabling +`crabidy` also drops `orphans`, which is a view over the store. A disabled +provider's own config file (`tidaly.toml`, etc.) is simply left unread. + +#### Roles and rights By default the server is open: everyone who can reach the port has full control. Adding an `[auth]` section turns on HTTP basic auth for diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index 000d807..be67384 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -17,7 +17,7 @@ use crabidy_core::proto::crabidy::{ crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue, Track, }; -use crabidy_core::{ProviderClient, ProviderError}; +use crabidy_core::ProviderError; use rand::{rng, seq::SliceRandom}; use std::sync::{atomic::AtomicBool, Arc}; use std::time::SystemTime; @@ -45,14 +45,21 @@ pub async fn serve( let config_dir = dirs::config_dir() .map(|d| d.join("crabidy")) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + // Seed a default config (all providers, no auth) on first run so users + // have the full provider list to prune. Best-effort — a write failure just + // means we fall back to the all-enabled default below. + if let Err(err) = settings::ServerSettings::ensure_default(&config_dir) { + warn!("could not write a default crabidy-server.toml: {err}"); + } let server_settings = settings::ServerSettings::load(&config_dir)?; let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth)); if authenticator.enabled() { info!("role authorization enabled"); } + let toggles = server_settings.provider_toggles(); let (update_tx, _) = tokio::sync::broadcast::channel(2048); - let orchestrator = provider::ProviderOrchestrator::init("") + let orchestrator = provider::ProviderOrchestrator::build(toggles) .await .map_err(|err| { error!("failed to init provider orchestrator: {err}"); diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 9bc99d3..a56e7ab 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -1,5 +1,6 @@ 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::{ @@ -13,7 +14,10 @@ use tracing::{debug, debug_span, error, instrument, warn, Instrument}; pub struct ProviderOrchestrator { pub provider_tx: flume::Sender, provider_rx: flume::Receiver, - tidal_client: Arc, + /// 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>, @@ -57,6 +61,15 @@ fn orphans_owns(path: &str) -> bool { } 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> { @@ -224,10 +237,15 @@ impl ProviderOrchestrator { } } -#[async_trait] -impl ProviderClient for ProviderOrchestrator { - #[instrument(skip(_s))] - async fn init(_s: &str) -> Result { +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")); @@ -239,56 +257,70 @@ impl ProviderClient for ProviderOrchestrator { .await .map_err(|e| ProviderError::Config(e.to_string()))?; } - 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 tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err( - |err| { - error!("failed to init tidal client: {err}"); - err - }, - )?); - let new_toml_config = tidal_client.settings(); - if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await { - error!("failed to write tidal config file: {err}"); + // 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_config_file = config_dir.join("fsdy.toml"); - debug!(config_file = %fs_config_file.display(), "loading fs config"); - let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default(); - let fs_client = match fsdy::Client::init(&raw_fs_settings).await { - Ok(client) => { - if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await { - error!("failed to write fsdy config file: {err}"); + 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 } - 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 = 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}"); + 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 } - }, - _ => { - 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 @@ -310,33 +342,42 @@ impl ProviderClient for ProviderOrchestrator { } } }); - // `/orphans`: a view over the store, mounted only when the store is. - // 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 = 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)) - }); + // `/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 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(); - let youtube_client = 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}"); + 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 } - Some(Arc::new(client)) - } - Err(err) => { - warn!("youtube provider disabled: {err}"); - None } + } else { + None }; let (provider_tx, provider_rx) = flume::bounded(100); Ok(Self { @@ -350,6 +391,17 @@ impl ProviderClient for ProviderOrchestrator { 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() @@ -358,7 +410,10 @@ impl ProviderClient for ProviderOrchestrator { /// 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.is_track_path(path); + return self + .tidal_client + .as_ref() + .is_some_and(|tidal| tidal.is_track_path(path)); } if fs_owns(path) { return self @@ -388,7 +443,7 @@ impl ProviderClient for ProviderOrchestrator { #[instrument(skip(self))] async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { if track_path.starts_with("/tidal/") { - return self.tidal_client.get_urls_for_track(track_path).await; + 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; @@ -418,7 +473,10 @@ impl ProviderClient for ProviderOrchestrator { #[instrument(skip(self))] async fn get_metadata_for_track(&self, track_path: &str) -> Result { if track_path.starts_with("/tidal/") { - return self.tidal_client.get_metadata_for_track(track_path).await; + 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; @@ -447,9 +505,11 @@ impl ProviderClient for ProviderOrchestrator { fn get_lib_root(&self) -> LibraryNode { let mut root_node = LibraryNode::new(); - let child = - LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false); - root_node.children.push(child); + 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); @@ -486,7 +546,7 @@ impl ProviderClient for ProviderOrchestrator { return Ok(self.get_lib_root()); } let mut node = if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - self.tidal_client.get_lib_node(path).await? + 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) { @@ -516,7 +576,10 @@ impl ProviderClient for ProviderOrchestrator { title: &str, ) -> Result { if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") { - return self.tidal_client.create_lib_node(parent_path, title).await; + return self + .tidal_provider()? + .create_lib_node(parent_path, title) + .await; } if fs_owns(parent_path) { return self @@ -555,7 +618,10 @@ impl ProviderClient for ProviderOrchestrator { new_title: &str, ) -> Result { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - return self.tidal_client.rename_lib_node(path, new_title).await; + 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; @@ -591,7 +657,10 @@ impl ProviderClient for ProviderOrchestrator { chunk_tx: flume::Sender>, ) -> Result<(), ProviderError> { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - return self.tidal_client.resolve_tracks_into(path, chunk_tx).await; + return self + .tidal_provider()? + .resolve_tracks_into(path, chunk_tx) + .await; } if fs_owns(path) { return self @@ -626,7 +695,7 @@ impl ProviderClient for ProviderOrchestrator { #[instrument(skip(self))] async fn delete_lib_node(&self, path: &str) -> Result { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { - return self.tidal_client.delete_lib_node(path).await; + return self.tidal_provider()?.delete_lib_node(path).await; } if fs_owns(path) { return self.fs_provider()?.delete_lib_node(path).await; diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index 41b14aa..e91c150 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -1,11 +1,12 @@ //! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`. //! -//! Today this only carries the `[auth]` role hashes -//! (architecture/roles-auth.md). The file is optional — a missing file -//! runs the server open, exactly as before the feature — but a file -//! that exists and does not parse aborts startup: silently ignoring a -//! broken auth config would run an intended-to-be-locked server open -//! (fail-closed, quality/roles-auth.md). +//! Carries the enabled-providers list ([`ServerSettings::providers`]) and the +//! `[auth]` role hashes (architecture/roles-auth.md). The server writes a +//! default (all providers, no auth) on first start via +//! [`ServerSettings::ensure_default`]. The file is optional — a missing file +//! runs the server open with every provider — but a file that exists and does +//! not parse aborts startup: silently ignoring a broken auth config would run +//! an intended-to-be-locked server open (fail-closed, quality/roles-auth.md). use std::path::Path; @@ -14,15 +15,51 @@ use serde::{Deserialize, Serialize}; /// The server config file name inside the crabidy config directory. pub const SETTINGS_FILE: &str = "crabidy-server.toml"; +/// Every built-in provider, in the order the default config lists them. Each +/// name is a library root (`/tidal`, `/youtube`, `/fs`, `/crabidy`, +/// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`. +pub const ALL_PROVIDERS: [&str; 5] = ["tidal", "youtube", "fs", "crabidy", "orphans"]; + /// Contents of `crabidy-server.toml`. #[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ServerSettings { + /// Enabled providers. The default config we write lists all of them + /// ([`ALL_PROVIDERS`]); remove a name to disable that provider. Absent — + /// the key deleted, or a fresh install with no file — enables all of them, + /// so a server never silently loses every provider (fail-open for the + /// library, unlike auth). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub providers: Option>, /// Role credentials; absent (or empty) means the server runs open. - #[serde(default)] + #[serde(default, skip_serializing_if = "AuthSettings::is_default")] pub auth: AuthSettings, } +/// Which providers to mount, resolved from [`ServerSettings::providers`]. +#[derive(Debug, Clone, Copy)] +pub struct ProviderToggles { + pub tidal: bool, + pub youtube: bool, + pub fs: bool, + pub crabidy: bool, + pub orphans: bool, +} + +impl ProviderToggles { + /// Every provider on — the default, and what a missing/keyless config + /// yields. + pub fn all() -> Self { + Self { + tidal: true, + youtube: true, + fs: true, + crabidy: true, + orphans: true, + } + } +} + /// One PHC password hash per role; a role without a hash cannot /// authenticate. Generate hashes with `crabidy-server guard `. /// Hashes are not passwords, but the file should stay private anyway. @@ -43,6 +80,12 @@ impl AuthSettings { pub fn enabled(&self) -> bool { self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some() } + + /// Whether this is the empty default (no roles) — so the auto-written + /// default config omits the `[auth]` table entirely. + fn is_default(&self) -> bool { + self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none() + } } impl ServerSettings { @@ -63,7 +106,46 @@ impl ServerSettings { toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) } - /// Serializes the current `[auth]` back to `crabidy-server.toml` in + /// Whether the named provider is enabled: every provider when the + /// `providers` key is absent, otherwise only the names the list holds. + pub fn provider_enabled(&self, name: &str) -> bool { + self.providers + .as_ref() + .is_none_or(|list| list.iter().any(|p| p == name)) + } + + /// The per-provider mount decisions for the orchestrator. `orphans` also + /// requires `crabidy` (it is a view over the store); that dependency is + /// enforced where the store is built, so it is not folded in here. + pub fn provider_toggles(&self) -> ProviderToggles { + ProviderToggles { + tidal: self.provider_enabled("tidal"), + youtube: self.provider_enabled("youtube"), + fs: self.provider_enabled("fs"), + crabidy: self.provider_enabled("crabidy"), + orphans: self.provider_enabled("orphans"), + } + } + + /// Writes a default `crabidy-server.toml` — all providers enabled, no auth + /// — when none exists yet, so users have a full list to prune. A file that + /// already exists (even a pruned one) is left untouched. Best-effort: the + /// caller treats a write failure as a warning, not a startup error. + pub fn ensure_default(config_dir: &Path) -> Result<(), String> { + let file = config_dir.join(SETTINGS_FILE); + match std::fs::metadata(&file) { + Ok(_) => return Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("cannot check {}: {err}", file.display())), + } + let settings = ServerSettings { + providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()), + auth: AuthSettings::default(), + }; + settings.store(config_dir) + } + + /// Serializes the current settings back to `crabidy-server.toml` in /// `config_dir`, creating the directory if missing. /// /// The flat `[auth]` shape is preserved (each role a top-level key), so a @@ -143,6 +225,79 @@ mod tests { assert!(nested.join(SETTINGS_FILE).is_file()); } + #[test] + fn an_absent_providers_key_enables_everything() { + let settings = ServerSettings::default(); + for provider in ALL_PROVIDERS { + assert!(settings.provider_enabled(provider), "{provider}"); + } + let toggles = settings.provider_toggles(); + assert!( + toggles.tidal && toggles.youtube && toggles.fs && toggles.crabidy && toggles.orphans + ); + } + + #[test] + fn an_explicit_list_enables_only_its_names() { + let dir = TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(SETTINGS_FILE), + "providers = [\"fs\", \"crabidy\"]\n", + ) + .expect("write"); + let settings = ServerSettings::load(dir.path()).expect("load"); + assert!(settings.provider_enabled("fs") && settings.provider_enabled("crabidy")); + assert!(!settings.provider_enabled("tidal")); + assert!(!settings.provider_enabled("youtube")); + assert!(!settings.provider_enabled("orphans")); + let toggles = settings.provider_toggles(); + assert!(toggles.fs && toggles.crabidy); + assert!(!toggles.tidal && !toggles.youtube && !toggles.orphans); + } + + #[test] + fn ensure_default_writes_all_providers_and_never_clobbers() { + let dir = TempDir::new().expect("tempdir"); + ServerSettings::ensure_default(dir.path()).expect("write default"); + let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); + for provider in ALL_PROVIDERS { + assert!(text.contains(provider), "default lists {provider}"); + } + assert!( + !text.contains("[auth]"), + "no empty auth table in the default" + ); + let reloaded = ServerSettings::load(dir.path()).expect("reload"); + assert_eq!( + reloaded.providers.as_ref().map(Vec::len), + Some(ALL_PROVIDERS.len()) + ); + // A second call must not overwrite a file the user has since pruned. + std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune"); + ServerSettings::ensure_default(dir.path()).expect("no-op"); + let after = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); + assert_eq!(after.trim(), "providers = [\"fs\"]"); + } + + #[test] + fn providers_and_auth_round_trip() { + let dir = TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(SETTINGS_FILE), + "providers = [\"tidal\", \"fs\"]\n[auth]\nowner = \"$argon2id$x\"\n", + ) + .expect("seed"); + let settings = ServerSettings::load(dir.path()).expect("load"); + assert!(settings.auth.enabled()); + settings.store(dir.path()).expect("store"); + let reloaded = ServerSettings::load(dir.path()).expect("reload"); + assert_eq!( + reloaded.providers, + Some(vec!["tidal".to_string(), "fs".to_string()]) + ); + assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x")); + } + #[test] fn a_broken_file_is_a_startup_error_not_an_open_server() { let dir = TempDir::new().expect("tempdir"); diff --git a/docs/src/config.md b/docs/src/config.md index 88dce47..43490a7 100644 --- a/docs/src/config.md +++ b/docs/src/config.md @@ -6,11 +6,12 @@ Crabidy reads its configuration from TOML files in `~/.config/crabidy/`, the platform config directory. Every file is optional. On first start each component writes its own file filled in with defaults, then reads it back — so a fresh install runs with sensible values and leaves you an -editable file for each piece. A provider whose file is missing simply -does not mount; it does not stop the server. +editable file for each piece. A provider whose config fails to load +simply does not mount; it does not stop the server. -The one exception is the server auth file, `crabidy-server.toml`: it is -never auto-created (see [Roles and authorization](./auth.md)). +Which providers mount at all is controlled by `crabidy-server.toml` (see +[Enabling and disabling providers](#enabling-and-disabling-providers)), +which the server now also writes on first start. ## The config files @@ -21,14 +22,29 @@ never auto-created (see [Roles and authorization](./auth.md)). | `fsdy.toml` | local fs | yes | | `cbd-tui.toml` | `cbd-tui` | yes | | `cbd.toml` | `cbd` | yes | -| `crabidy-server.toml` | server | no | +| `crabidy-server.toml` | server | yes | - `tidaly.toml`, `ytdy.toml`, and `fsdy.toml` configure the three media providers — Tidal, YouTube, and a local music folder (its filesystem root). See [Providers](./providers.md). - `cbd-tui.toml` and `cbd.toml` are client configs (below). -- `crabidy-server.toml` holds server auth (see - [Roles and authorization](./auth.md)). +- `crabidy-server.toml` holds the enabled-providers list (below) and server + auth (see [Roles and authorization](./auth.md)). + +## Enabling and disabling providers + +On first start the server writes `crabidy-server.toml` with every provider +enabled: + +```toml +providers = ["tidal", "youtube", "fs", "crabidy", "orphans"] +``` + +**Remove a name to disable that provider** — it no longer mounts and drops +out of the library tree; its own config file (e.g. `tidaly.toml`) is then +left unread. Deleting the whole `providers` line re-enables everything (the +same as a fresh install with no file). Because `orphans` is a view over the +store, disabling `crabidy` disables `orphans` too. ## Client config: `cbd-tui.toml` and `cbd.toml`