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:
parent
3a03114cb9
commit
d078e65a7d
18
README.md
18
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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// server runs without `/fs` instead of dying (architecture D5).
|
||||
fs_client: Option<Arc<fsdy::Client>>,
|
||||
|
|
@ -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<Self, ProviderError> {
|
||||
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<Self, ProviderError> {
|
||||
let config_dir = dirs::config_dir()
|
||||
.map(|d| d.join("crabidy"))
|
||||
.unwrap_or(PathBuf::from("/tmp"));
|
||||
|
|
@ -239,25 +257,32 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
.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 tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err(
|
||||
|err| {
|
||||
let client = 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 {
|
||||
})?;
|
||||
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();
|
||||
let fs_client = match fsdy::Client::init(&raw_fs_settings).await {
|
||||
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}");
|
||||
|
|
@ -268,13 +293,17 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
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 (
|
||||
let crabidy_store = if enabled.crabidy {
|
||||
match (
|
||||
CrabidyStore::default_tree_root(),
|
||||
CrabidyStore::default_store_root(),
|
||||
) {
|
||||
|
|
@ -289,6 +318,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
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,23 +342,29 @@ 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| {
|
||||
// `/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();
|
||||
let youtube_client = match ytdy::Client::init(&raw_yt_settings).await {
|
||||
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}");
|
||||
|
|
@ -337,6 +375,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
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, ProviderError> {
|
||||
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<Vec<String>, 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<Track, ProviderError> {
|
||||
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();
|
||||
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<LibraryNode, ProviderError> {
|
||||
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<LibraryNode, ProviderError> {
|
||||
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<Vec<Track>>,
|
||||
) -> 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<LibraryNode, ProviderError> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Vec<String>>,
|
||||
/// 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 <role>`.
|
||||
/// 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");
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue