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) |
|
| `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
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue