Let providers be enabled/disabled via crabidy-server.toml

The server now writes a default crabidy-server.toml on first start listing
every provider:

    providers = ["tidal", "youtube", "fs", "crabidy", "orphans"]

Removing a name disables that provider — it no longer mounts and drops out
of the library; its own config file is left unread. An absent providers key
(a deleted line, or a fresh install with no file) enables all of them, so a
server never silently loses its whole library. Disabling crabidy also drops
orphans, which is a view over the store.

ServerSettings gains the providers list, provider_enabled/provider_toggles,
and ensure_default (best-effort first-run seed). ProviderOrchestrator::init
becomes ::build(ProviderToggles), gating each provider; the tidal client is
now Option like the others (still fatal-on-error when enabled, skipped when
disabled). README and the mdbook document the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 00:27:02 +02:00
parent 3a03114cb9
commit d078e65a7d
5 changed files with 355 additions and 94 deletions

View File

@ -57,7 +57,7 @@ filled in.
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | | `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
| `cbd-tui.toml` | `cbd-tui` | below | | `cbd-tui.toml` | `cbd-tui` | below |
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) | | `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 The server-managed `crabidy` provider does not live under `~/.config`. Its
track-file tree (saved queues, bookmarks, and captures) lives in 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 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 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 full control. Adding an `[auth]` section turns on HTTP basic auth for

View File

@ -17,7 +17,7 @@ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue, crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
Track, Track,
}; };
use crabidy_core::{ProviderClient, ProviderError}; use crabidy_core::ProviderError;
use rand::{rng, seq::SliceRandom}; use rand::{rng, seq::SliceRandom};
use std::sync::{atomic::AtomicBool, Arc}; use std::sync::{atomic::AtomicBool, Arc};
use std::time::SystemTime; use std::time::SystemTime;
@ -45,14 +45,21 @@ pub async fn serve(
let config_dir = dirs::config_dir() let config_dir = dirs::config_dir()
.map(|d| d.join("crabidy")) .map(|d| d.join("crabidy"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp")); .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 server_settings = settings::ServerSettings::load(&config_dir)?;
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth)); let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
if authenticator.enabled() { if authenticator.enabled() {
info!("role authorization enabled"); info!("role authorization enabled");
} }
let toggles = server_settings.provider_toggles();
let (update_tx, _) = tokio::sync::broadcast::channel(2048); let (update_tx, _) = tokio::sync::broadcast::channel(2048);
let orchestrator = provider::ProviderOrchestrator::init("") let orchestrator = provider::ProviderOrchestrator::build(toggles)
.await .await
.map_err(|err| { .map_err(|err| {
error!("failed to init provider orchestrator: {err}"); error!("failed to init provider orchestrator: {err}");

View File

@ -1,5 +1,6 @@
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME}; use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT}; use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT};
use crate::settings::ProviderToggles;
use crate::{ProviderCommand, ProviderMessage}; use crate::{ProviderCommand, ProviderMessage};
use async_trait::async_trait; use async_trait::async_trait;
use crabidy_core::{ use crabidy_core::{
@ -13,7 +14,10 @@ use tracing::{debug, debug_span, error, instrument, warn, Instrument};
pub struct ProviderOrchestrator { pub struct ProviderOrchestrator {
pub provider_tx: flume::Sender<ProviderMessage>, pub provider_tx: flume::Sender<ProviderMessage>,
provider_rx: flume::Receiver<ProviderMessage>, provider_rx: flume::Receiver<ProviderMessage>,
tidal_client: Arc<tidaldy::Client>, /// 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<Arc<tidaldy::Client>>,
/// `None` when the filesystem provider failed to initialize — the /// `None` when the filesystem provider failed to initialize — the
/// server runs without `/fs` instead of dying (architecture D5). /// server runs without `/fs` instead of dying (architecture D5).
fs_client: Option<Arc<fsdy::Client>>, fs_client: Option<Arc<fsdy::Client>>,
@ -57,6 +61,15 @@ fn orphans_owns(path: &str) -> bool {
} }
impl ProviderOrchestrator { 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 /// The fs client, or `MalformedPath` (with a warning) when the
/// provider is disabled — a `/fs` path then has no owner. /// provider is disabled — a `/fs` path then has no owner.
fn fs_provider(&self) -> Result<&fsdy::Client, ProviderError> { fn fs_provider(&self) -> Result<&fsdy::Client, ProviderError> {
@ -224,10 +237,15 @@ impl ProviderOrchestrator {
} }
} }
#[async_trait] impl ProviderOrchestrator {
impl ProviderClient for ProviderOrchestrator { /// Builds the orchestrator, mounting only the providers `enabled` turns on
#[instrument(skip(_s))] /// (from `crabidy-server.toml`'s `providers` list). Tidal, when enabled,
async fn init(_s: &str) -> Result<Self, ProviderError> { /// 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<Self, ProviderError> {
let config_dir = dirs::config_dir() let config_dir = dirs::config_dir()
.map(|d| d.join("crabidy")) .map(|d| d.join("crabidy"))
.unwrap_or(PathBuf::from("/tmp")); .unwrap_or(PathBuf::from("/tmp"));
@ -239,56 +257,70 @@ impl ProviderClient for ProviderOrchestrator {
.await .await
.map_err(|e| ProviderError::Config(e.to_string()))?; .map_err(|e| ProviderError::Config(e.to_string()))?;
} }
let config_file = config_dir.join("tidaly.toml"); // Tidal: skipped when disabled; when enabled a broken config is still
debug!(config_file = %config_file.display(), "loading tidal config"); // fatal (unlike the local providers), preserving prior behavior.
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default(); let tidal_client = if enabled.tidal {
let tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err( let config_file = config_dir.join("tidaly.toml");
|err| { debug!(config_file = %config_file.display(), "loading tidal config");
error!("failed to init tidal client: {err}"); let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default();
err let client = tidaldy::Client::init(&raw_toml_settings)
}, .await
)?); .map_err(|err| {
let new_toml_config = tidal_client.settings(); error!("failed to init tidal client: {err}");
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await { err
error!("failed to write tidal config file: {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 // The filesystem provider is optional: a broken local config only
// costs the `/fs` subtree, never the server. // costs the `/fs` subtree, never the server.
let fs_config_file = config_dir.join("fsdy.toml"); let fs_client = if enabled.fs {
debug!(config_file = %fs_config_file.display(), "loading fs config"); let fs_config_file = config_dir.join("fsdy.toml");
let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default(); debug!(config_file = %fs_config_file.display(), "loading fs config");
let fs_client = match fsdy::Client::init(&raw_fs_settings).await { let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
Ok(client) => { match fsdy::Client::init(&raw_fs_settings).await {
if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await { Ok(client) => {
error!("failed to write fsdy config file: {err}"); 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 // The single `/crabidy` provider: one content store + toml tree
// (architecture/crabidy-store.md). The store owns both roots (state // (architecture/crabidy-store.md). The store owns both roots (state
// tree + data store) and is the sole writer; the mounted `fsdy` // tree + data store) and is the sole writer; the mounted `fsdy`
// client reads the tree and resolves store playables. Non-fatal like // client reads the tree and resolves store playables. Non-fatal like
// the other local providers — no state/data dir just drops `/crabidy`. // the other local providers — no state/data dir just drops `/crabidy`.
let crabidy_store = match ( let crabidy_store = if enabled.crabidy {
CrabidyStore::default_tree_root(), match (
CrabidyStore::default_store_root(), CrabidyStore::default_tree_root(),
) { CrabidyStore::default_store_root(),
(Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await { ) {
Ok(store) => Some(Arc::new(store)), (Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await {
Err(err) => { Ok(store) => Some(Arc::new(store)),
warn!("crabidy library disabled: {err}"); Err(err) => {
warn!("crabidy library disabled: {err}");
None
}
},
_ => {
warn!("crabidy library disabled: no state/data directory");
None None
} }
},
_ => {
warn!("crabidy library disabled: no state/data directory");
None
} }
} else {
None
}; };
// Saved queues/bookmarks are renamable and deletable; the // Saved queues/bookmarks are renamable and deletable; the
// auto-persisted `current` stays untouchable. The whole tree is // 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. // `/orphans`: a view over the store, so it needs both its own toggle
// Its reference roots are the disk roots of the mounted file providers // and a live store. Its reference roots are the disk roots of the
// — the `/crabidy` toml tree and (when enabled) the `/fs` root — the // mounted file providers — the `/crabidy` toml tree and (when enabled)
// only places a `Playable::Store` link can live (architecture/orphans.md). // the `/fs` root — the only places a `Playable::Store` link can live
let orphans_client = crabidy_store.as_ref().map(|store| { // (architecture/orphans.md).
let mut ref_roots = vec![store.tree_dir().to_path_buf()]; let orphans_client = if enabled.orphans {
if let Some(fs) = &fs_client { crabidy_store.as_ref().map(|store| {
ref_roots.push(fs.disk_root().to_path_buf()); let mut ref_roots = vec![store.tree_dir().to_path_buf()];
} if let Some(fs) = &fs_client {
Arc::new(OrphansProvider::new(Arc::clone(store), ref_roots)) 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 // YouTube: non-fatal like the local providers — a missing or
// broken yt-dlp binary only costs the `/youtube` subtree. // broken yt-dlp binary only costs the `/youtube` subtree.
let yt_config_file = config_dir.join("ytdy.toml"); let youtube_client = if enabled.youtube {
debug!(config_file = %yt_config_file.display(), "loading youtube config"); let yt_config_file = config_dir.join("ytdy.toml");
let raw_yt_settings = fs::read_to_string(&yt_config_file).unwrap_or_default(); debug!(config_file = %yt_config_file.display(), "loading youtube config");
let youtube_client = match ytdy::Client::init(&raw_yt_settings).await { let raw_yt_settings = fs::read_to_string(&yt_config_file).unwrap_or_default();
Ok(client) => { match ytdy::Client::init(&raw_yt_settings).await {
if let Err(err) = tokio::fs::write(&yt_config_file, client.settings()).await { Ok(client) => {
error!("failed to write ytdy config file: {err}"); 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); let (provider_tx, provider_rx) = flume::bounded(100);
Ok(Self { Ok(Self {
@ -350,6 +391,17 @@ impl ProviderClient for ProviderOrchestrator {
youtube_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, ProviderError> {
Self::build(ProviderToggles::all()).await
}
fn settings(&self) -> String { fn settings(&self) -> String {
String::new() String::new()
@ -358,7 +410,10 @@ impl ProviderClient for ProviderOrchestrator {
/// Routes to the provider that owns the path. /// Routes to the provider that owns the path.
fn is_track_path(&self, path: &str) -> bool { fn is_track_path(&self, path: &str) -> bool {
if path == "/tidal" || path.starts_with("/tidal/") { 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) { if fs_owns(path) {
return self return self
@ -388,7 +443,7 @@ impl ProviderClient for ProviderOrchestrator {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> { async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
if track_path.starts_with("/tidal/") { 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) { if fs_owns(track_path) {
return self.fs_provider()?.get_urls_for_track(track_path).await; return self.fs_provider()?.get_urls_for_track(track_path).await;
@ -418,7 +473,10 @@ impl ProviderClient for ProviderOrchestrator {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> { async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
if track_path.starts_with("/tidal/") { 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) { if fs_owns(track_path) {
return self.fs_provider()?.get_metadata_for_track(track_path).await; 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 { fn get_lib_root(&self) -> LibraryNode {
let mut root_node = LibraryNode::new(); let mut root_node = LibraryNode::new();
let child = if self.tidal_client.is_some() {
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false); let child =
root_node.children.push(child); LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
root_node.children.push(child);
}
if self.fs_client.is_some() { if self.fs_client.is_some() {
let child = let child =
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false); 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()); return Ok(self.get_lib_root());
} }
let mut node = if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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) { } else if fs_owns(path) {
self.fs_provider()?.get_lib_node(path).await? self.fs_provider()?.get_lib_node(path).await?
} else if crabidy_owns(path) { } else if crabidy_owns(path) {
@ -516,7 +576,10 @@ impl ProviderClient for ProviderOrchestrator {
title: &str, title: &str,
) -> Result<LibraryNode, ProviderError> { ) -> Result<LibraryNode, ProviderError> {
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") { 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) { if fs_owns(parent_path) {
return self return self
@ -555,7 +618,10 @@ impl ProviderClient for ProviderOrchestrator {
new_title: &str, new_title: &str,
) -> Result<LibraryNode, ProviderError> { ) -> Result<LibraryNode, ProviderError> {
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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) { if fs_owns(path) {
return self.fs_provider()?.rename_lib_node(path, new_title).await; return self.fs_provider()?.rename_lib_node(path, new_title).await;
@ -591,7 +657,10 @@ impl ProviderClient for ProviderOrchestrator {
chunk_tx: flume::Sender<Vec<Track>>, chunk_tx: flume::Sender<Vec<Track>>,
) -> Result<(), ProviderError> { ) -> Result<(), ProviderError> {
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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) { if fs_owns(path) {
return self return self
@ -626,7 +695,7 @@ impl ProviderClient for ProviderOrchestrator {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> { async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { 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) { if fs_owns(path) {
return self.fs_provider()?.delete_lib_node(path).await; return self.fs_provider()?.delete_lib_node(path).await;

View File

@ -1,11 +1,12 @@
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`. //! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
//! //!
//! Today this only carries the `[auth]` role hashes //! Carries the enabled-providers list ([`ServerSettings::providers`]) and the
//! (architecture/roles-auth.md). The file is optional — a missing file //! `[auth]` role hashes (architecture/roles-auth.md). The server writes a
//! runs the server open, exactly as before the feature — but a file //! default (all providers, no auth) on first start via
//! that exists and does not parse aborts startup: silently ignoring a //! [`ServerSettings::ensure_default`]. The file is optional — a missing file
//! broken auth config would run an intended-to-be-locked server open //! runs the server open with every provider — but a file that exists and does
//! (fail-closed, quality/roles-auth.md). //! 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; use std::path::Path;
@ -14,15 +15,51 @@ use serde::{Deserialize, Serialize};
/// The server config file name inside the crabidy config directory. /// The server config file name inside the crabidy config directory.
pub const SETTINGS_FILE: &str = "crabidy-server.toml"; 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`. /// Contents of `crabidy-server.toml`.
#[derive(Debug, Default, Deserialize, Serialize)] #[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ServerSettings { 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<Vec<String>>,
/// Role credentials; absent (or empty) means the server runs open. /// Role credentials; absent (or empty) means the server runs open.
#[serde(default)] #[serde(default, skip_serializing_if = "AuthSettings::is_default")]
pub auth: AuthSettings, 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 /// One PHC password hash per role; a role without a hash cannot
/// authenticate. Generate hashes with `crabidy-server guard <role>`. /// authenticate. Generate hashes with `crabidy-server guard <role>`.
/// Hashes are not passwords, but the file should stay private anyway. /// Hashes are not passwords, but the file should stay private anyway.
@ -43,6 +80,12 @@ impl AuthSettings {
pub fn enabled(&self) -> bool { pub fn enabled(&self) -> bool {
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some() 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 { impl ServerSettings {
@ -63,7 +106,46 @@ impl ServerSettings {
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) 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. /// `config_dir`, creating the directory if missing.
/// ///
/// The flat `[auth]` shape is preserved (each role a top-level key), so a /// 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()); 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] #[test]
fn a_broken_file_is_a_startup_error_not_an_open_server() { fn a_broken_file_is_a_startup_error_not_an_open_server() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");

View File

@ -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 the platform config directory. Every file is optional. On first start
each component writes its own file filled in with defaults, then reads 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 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 editable file for each piece. A provider whose config fails to load
does not mount; it does not stop the server. simply does not mount; it does not stop the server.
The one exception is the server auth file, `crabidy-server.toml`: it is Which providers mount at all is controlled by `crabidy-server.toml` (see
never auto-created (see [Roles and authorization](./auth.md)). [Enabling and disabling providers](#enabling-and-disabling-providers)),
which the server now also writes on first start.
## The config files ## The config files
@ -21,14 +22,29 @@ never auto-created (see [Roles and authorization](./auth.md)).
| `fsdy.toml` | local fs | yes | | `fsdy.toml` | local fs | yes |
| `cbd-tui.toml` | `cbd-tui` | yes | | `cbd-tui.toml` | `cbd-tui` | yes |
| `cbd.toml` | `cbd` | 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 - `tidaly.toml`, `ytdy.toml`, and `fsdy.toml` configure the three media
providers — Tidal, YouTube, and a local music folder (its filesystem providers — Tidal, YouTube, and a local music folder (its filesystem
root). See [Providers](./providers.md). root). See [Providers](./providers.md).
- `cbd-tui.toml` and `cbd.toml` are client configs (below). - `cbd-tui.toml` and `cbd.toml` are client configs (below).
- `crabidy-server.toml` holds server auth (see - `crabidy-server.toml` holds the enabled-providers list (below) and server
[Roles and authorization](./auth.md)). 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` ## Client config: `cbd-tui.toml` and `cbd.toml`