build: put every provider, opus, and the spectrum behind cargo features

All on by default, so a plain build is unchanged (verified: the default
dependency set for crabidy-server is byte-identical to before). Tailor a
smaller binary with --no-default-features --features …
(architecture/build-features.md).

Compile-time features draw dependency boundaries; the existing
crabidy-server.toml providers list keeps doing per-mount runtime
toggling. The compile-time set bounds the runtime one: a provider built
out cannot be enabled from the config, and naming it earns one startup
warning rather than silence.

- crabidy-server: tidal · youtube · fyyd · abs · soundcloud · jamendo ·
  fs · opus · spectrum · web-ui, plus the all-providers group.
- fs is local files *and* persistent state (D5): the /fs mount, the
  content store behind /crabidy and /orphans, bookmarks/captures, queue
  persistence, and scan. Without it Capture/SaveQueue answer
  Unimplemented and scan says which feature is missing — never a panic.
- opus drops symphonia + symphonia-adapter-libopus, and with them the
  bundled libopus C build (no more cmake requirement). It also decides
  whether scan indexes .opus at all, so scan never indexes what this
  build cannot play. An Ogg-Opus file in an opus-less build reports the
  missing feature and is skipped like any undecodable file.
- spectrum drops realfft and the FFT task; clients just never receive a
  frame. cbd-tui gains notifications (notify-rust, a D-Bus stack).
- crabidy-server/cbd features print the compiled set, and startup logs
  it, so a tailored binary is self-describing.

Not gated, deliberately: [auth]/argon2 (a build ignoring configured
hashes would run open — fail-open security hole), and hls.rs /
spectrum_tap.rs / windowed_http.rs (no dependency of their own, so
gating them buys cfg noise and nothing else).

devenv gains check-features: the curated matrix (defaults, nothing, each
provider alone, each axis dropped, both worked examples, the client
crates) all clippy-clean under -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-25 03:15:32 +02:00
parent 1b838578e2
commit a03e3de84e
21 changed files with 1439 additions and 227 deletions

View File

@ -101,13 +101,16 @@ tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2" url = "2"
# Local crates # Local crates. `default-features = false` on the three feature-carrying
# crates: their dependents select what they want (a member cannot *drop* a
# workspace-inherited default), which is how a tailored build stays tailored —
# see architecture/build-features.md D1.
absdy = { path = "absdy" } absdy = { path = "absdy" }
audio-player = { path = "audio-player" } audio-player = { path = "audio-player", default-features = false }
cbd-cli = { path = "cbd-cli" } cbd-cli = { path = "cbd-cli" }
cbd-tui = { path = "cbd-tui" } cbd-tui = { path = "cbd-tui", default-features = false }
crabidy-core = { path = "crabidy-core" } crabidy-core = { path = "crabidy-core" }
crabidy-server = { path = "crabidy-server" } crabidy-server = { path = "crabidy-server", default-features = false }
fsdy = { path = "fsdy" } fsdy = { path = "fsdy" }
fyyd = { path = "fyyd" } fyyd = { path = "fyyd" }
jamendody = { path = "jamendody" } jamendody = { path = "jamendody" }

View File

@ -0,0 +1,350 @@
# Build features (tailored, non-bloated builds)
Cargo features that let a build drop whole subsystems — each provider,
Opus decoding, the spectrum, the embedded web UI, desktop notifications —
together with the dependencies those subsystems pull in. Everything is
**on by default**: a plain `cargo build` produces today's binary, and
`--no-default-features --features …` produces an appliance build.
## Context and problem statement
`crabidy-server` links every provider unconditionally: `tidaldy`,
`ytdy` (and through it `rustypipe`), `fyyd`, `absdy`, `soundclouddy`,
`jamendody`, `fsdy`. `audio-player` always links `symphonia` plus
`symphonia-adapter-libopus`, which **bundles libopus and therefore
requires `cmake` + `ninja` at build time**. `cbd-tui` always links
`notify-rust` (a D-Bus stack on Linux). A user who wants "a Raspberry Pi
that plays my local flac collection" compiles, links, and ships all of
it.
There is already a **runtime** switch — `crabidy-server.toml`'s
`providers = [...]` list ([`settings::ProviderToggles`]) — but it only
decides what gets *mounted*; every dependency is still compiled and
linked. The ask is the compile-time half, aligned with the dependency
graph so a tailored build is genuinely smaller.
Two questions from the request are answered here: `scan` **does**
already treat `.opus` as playable (`cli.rs: AUDIO_EXTENSIONS`), and that
entry now becomes conditional on the `opus` feature (D6); and disabling
`fs` also drops `/crabidy` and `/orphans` (D5).
## Assumptions (decided)
- **Default-on, opt-out.** No user's build changes unless they ask. The
entry point for tailoring is `--no-default-features`.
- **Two layers, different jobs.** Compile-time features decide what is
*linked*; the existing `providers` list decides what is *mounted*.
A provider that is compiled in can still be turned off in the toml;
a provider that is not compiled in cannot be turned on.
- **The wire protocol is feature-independent.** No `.proto` changes, no
feature-conditional RPCs, no client/server feature coupling. A client
asking for `/tidal` on a tidal-less server gets exactly what it gets
today from a runtime-disabled provider (`MalformedPath` → gRPC
`InvalidArgument`), and the root listing simply does not offer it.
Clients (`cbd-tui`, `cbd-web`) need **no** knowledge of server
features.
- **A feature must pay for itself in dependencies.** Adding `#[cfg]`
noise to gate code that shares its dependencies with code that stays
is a net loss — that is what the runtime toggles are for. See D5 for
where this bites (`crabidy`/`orphans`) and D9 for what we refuse to
gate.
## Options considered
### How to gate the providers inside the orchestrator
`ProviderOrchestrator` holds nine `Option<Arc<ConcreteClient>>` fields
and dispatches with a hand-written `if …_owns(path) { … }` chain,
repeated verbatim across eight trait methods (`is_track_path`,
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
`resolve_tracks_into`) plus `get_lib_root`.
1. **Sprinkle `#[cfg(feature = …)]`** on every field, every
`*_owns`/`*_provider` helper, and every branch of every chain:
~130 attributes, nine of them per method body, in a 1000-line file
that then only compiles in one shape per feature combination.
Rejected: unmaintainable, and each new provider multiplies it.
2. **A mount registry** (chosen). The nine dispatch chains collapse into
one lookup, because every branch is already the *same* code modulo
the client. `ProviderClient` is dyn-compatible (its only non-`&self`
method, `init`, carries `where Self: Sized`), so mounts can be held
as `Arc<dyn ProviderClient>`:
```rust
struct Mount {
root: &'static str, // "/tidal", "/fs", …
name: &'static str, // root-listing title
client: Arc<dyn ProviderClient>,
}
```
Dispatch becomes "find the mount whose root owns this path, or
`MalformedPath`". A provider is then gated in exactly **one** place —
its registration in `build()` — plus its `Cargo.toml` line. This
deletes ~600 lines of repetition and is a strict prerequisite for the
feature work, so it lands first, on its own, with behaviour
unchanged.
Ordering of the root listing (crabidy first, orphans last, rest
alphabetical) is a property of the assembled child list and is
preserved by the registry (it sorts the same way).
### Where the feature flags live
`crabidy-server` is the hub and owns the user-facing names.
`audio-player` gets internal features that the server turns on
(`opus`, `hls`, `spectrum`); `cbd` (the bundle) forwards the server's
set so `-p cbd` is tailorable too; `cbd-tui` owns `notifications`.
`crabidy-core` stays feature-free — it is the shared proto/trait crate
and every configuration needs all of it.
## Decisions
**D1 — Feature set.** `crabidy-server`:
```toml
[features]
default = ["all-providers", "opus", "spectrum", "web-ui"]
all-providers = ["tidal", "youtube", "fyyd", "abs", "soundcloud",
"jamendo", "fs"]
tidal = ["dep:tidaldy"]
youtube = ["dep:ytdy"]
fyyd = ["dep:fyyd"]
abs = ["dep:absdy"]
soundcloud = ["dep:soundclouddy"]
jamendo = ["dep:jamendody"]
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest"]
opus = ["audio-player/opus"]
spectrum = ["dep:realfft"]
web-ui = ["dep:tonic-web", "dep:include_dir"]
```
`cbd` mirrors every one of them as a pass-through
(`tidal = ["crabidy-server/tidal"]`, …) and adds
`notifications = ["cbd-tui/notifications"]`. `cbd-tui`:
`default = ["notifications"]`, `notifications = ["dep:notify-rust"]`.
`audio-player` gets exactly one feature — `default = ["opus"]`,
`opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]` — for the
reason in D7.
**D2 — A build with no providers is legal.** `--no-default-features`
must compile and run: the server starts, serves an empty library root,
and plays nothing. It is the base case of the matrix (D11) and the
cheapest possible smoke test of the gating. It is not a *useful*
deployment, and the startup log says so.
**D3 — Names match the runtime toggles.** The feature names are exactly
the strings in `providers = [...]` (`tidal`, `youtube`, `fyyd`, `abs`,
`soundcloud`, `jamendo`, `fs`). One vocabulary for both layers.
**D4 — The compiled-in set is discoverable.** `settings` gains a
compile-time `BUILT_IN_PROVIDERS` (the feature-filtered version of
today's `ALL_PROVIDERS`). Consequences:
- the default `crabidy-server.toml` written on first run lists only
providers this binary has;
- a name in the user's list that this binary lacks logs one clear
warning at startup (`providers lists "tidal", but this binary was
built without it`) — a warning, not a startup abort, because the
library layer is fail-open by design (unlike `[auth]`);
- an unknown name (a typo) warns the same way;
- `crabidy-server features` / `cbd features` prints the compiled set
(providers + `opus`/`spectrum`/`web-ui`/`notifications`), and the
same list goes into one `info!` line at startup. "Why is `/tidal`
missing?" is then answerable from the binary and the log.
**D5 — `fs` owns local files *and* persistent state; `crabidy` and
`orphans` stay runtime-only.** As requested, disabling `fs` disables
`/crabidy` and `/orphans` too — but they are not separate *features*,
because they add no dependency of their own: `crabidy_store.rs`,
`capture.rs`, and `orphans.rs` are all written in terms of `fsdy` types
(`fsdy::TrackFile`, `Playable`, `AlbumMeta`, `dir_name`). Gating them
separately would buy nothing and cost three more `#[cfg]` dimensions.
So the `fs` feature is one coherent unit — "local files and persistent
state" — covering:
| gated by `fs` | consequence when off |
| --- | --- |
| `fsdy` client, `/fs` mount | no `/fs` |
| `crabidy_store` + `/crabidy` mount | no saved queues, bookmarks, captures |
| `orphans` + `/orphans` mount | no GC view |
| queue persistence | in-memory only; a restart starts empty |
| `capture` (bookmarks, downloads) | capture/save RPCs `Unimplemented` |
| `annotate_captured` | no captured markers (clients handle absent flags) |
| the `scan` CLI command | fails with "built without the `fs` feature" |
Users who want `/fs` but not `/crabidy` keep doing what they do today:
prune the `providers` list.
**D6 — `opus` is the biggest single win and is not tied to a provider.**
It drops `symphonia`, `symphonia-adapter-libopus`, and with them the
bundled libopus C build (`cmake` + `ninja` disappear from the build
requirements — the reason this feature is worth its `#[cfg]`s).
Ogg-Opus files reach the player from `/abs`, `/fs`, and `/crabidy`
alike, so it stays an independent axis. With `opus` off:
- `player_engine::build_source` skips the sniff and hands everything to
rodio's decoder — an Ogg-Opus file then fails to decode with a clear
error (`this build has no Opus decoder`) and playback skips the track,
exactly as any undecodable file does today. **No panic** (hard rule).
- `cli.rs: AUDIO_EXTENSIONS` drops `"opus"`, so `scan` no longer indexes
`.opus` files it could not play. (It *does* index them today; that is
correct behaviour for a build that has the decoder.)
**D7 — HLS playback is *not* gated.** SoundCloud is the only provider
that returns an `.m3u8` (`soundclouddy` resolves HLS media URLs), so
`audio-player/src/hls.rs` is dead code in a build without `soundcloud`
— but it imports only crates the player needs anyway (`bytes`,
`futures`, `stream-download`, `url`, `reqwest`). Gating it would buy a
few KB of code and cost a `#[cfg]` dimension across the decode path, so
it stays unconditional. Same reasoning for the windowed-HTTP source. The
rule this follows is the one in the assumptions: **a feature must pay
for itself in dependencies.**
**D8 — `spectrum` gates the server side only.** `realfft`, `spectrum.rs`
and `spawn_spectrum_task` go away; the `SpectrumFrame` proto message,
the player's sample tap, and both clients' rendering stay. A client
subscribed to the update stream simply never receives a frame, which it
already handles (the bars stay dark). The tap itself
(`audio-player/src/spectrum_tap.rs`) is not gated — like `hls.rs` it
brings no dependency (std + rodio), and it is woven through
`player_engine`'s decode path via `TappingSource`, so `Player`'s public
surface stays feature-invariant.
**D9 — Deliberately *not* behind features.**
- **`[auth]` / `argon2`.** A binary built without auth would ignore
configured role hashes and run open — a fail-open security hole for a
~200 KB dependency. Refused. (If it is ever added, it must *abort*
startup when `[auth]` is non-empty.)
- **Audio output / `rodio` / ALSA.** The server *is* the player; a
server with no audio output has no purpose here.
- **`web-ui` on the clients.** `cbd-web` is its own crate; not building
it is already the way to not have it.
- **TUI spectrum rendering, TUI/web feature parity.** No dependency
behind them (D8).
- **The CLI surface.** The clap definitions live in `cbd-cli`, which
depends on none of the gated crates. Keeping the surface constant
means completions and the man page do not vary per build; a command
whose backing feature is absent fails with a clear message (D5).
**D10 — `flake.nix` must be updated in the same change.** Its native
build passes a bare `--no-default-features` (today: "everything except
`web-ui`"). After D1 that would silently produce a **provider-less**
binary. It becomes an explicit list — `--no-default-features --features
all-providers,opus,spectrum` — and the aarch64 cross build (full
defaults, `web-ui` on) stays as is. `devenv.nix` gains scripts for the
tailored builds so the matrix is one command.
**D11 — Verification is a build matrix, not a powerset.** Feature
combinatorics are the real risk: unused imports/dead code under odd
combinations, and `-D warnings` in the pre-commit hook. A curated matrix
(D2's empty build, defaults, each provider alone, `fs`-only,
`opus`-off, `spectrum`-off, `web-ui`-off, and the two client crates)
gives the coverage that matters; `cargo hack --each-feature` is
available in devenv for a deeper sweep when the flags change.
## Structure
```d2
direction: right
features: crabidy-server features {
providers: "tidal · youtube · fyyd\nabs · soundcloud · jamendo · fs"
opus: opus
spectrum: spectrum
webui: web-ui
}
server: crabidy-server {
registry: "mount registry\nArc<dyn ProviderClient>"
store: "crabidy_store + capture\n+ orphans"
fft: "spectrum.rs (realfft)"
web: "web.rs (tonic-web,\ninclude_dir)"
}
player: audio-player {
ap_opus: "opus_source\n(symphonia + libopus,\nneeds cmake)"
ap_rest: "hls · windowed_http\n· spectrum_tap\n(never gated: no own deps)"
}
deps: provider crates {
tidaldy
ytdy: "ytdy → rustypipe"
fyyd
absdy
soundclouddy
jamendody
fsdy: "fsdy + blake3"
}
features.providers -> server.registry: mounts
features.providers -> deps: "dep:*"
features.providers -> server.store: "fs only"
features.opus -> player.ap_opus
features.spectrum -> server.fft
features.webui -> server.web
```
Dispatch after the registry refactor — one path, whatever is compiled
in:
```d2
direction: right
rpc: RPC / provider loop
root: "path == /"
lookup: "mount whose root owns the path"
client: "Arc<dyn ProviderClient>"
none: "MalformedPath / NotSupported"
listing: "root listing\n(crabidy, …, orphans)"
rpc -> root
root -> listing: yes
root -> lookup: no
lookup -> client: found
lookup -> none: no owner
```
## Boundaries and interfaces
- **`ProviderOrchestrator`** — the only place a provider is named. New
shape: `mounts: Vec<Mount>` plus the `Option<Arc<CrabidyStore>>` that
`fs` brings (the store is not a mount; it is a writer other
subsystems share). Public surface (`build`, `run`, `provider_tx`,
`crabidy_store`, the `ProviderClient` impl) is unchanged.
- **`settings`** — `BUILT_IN_PROVIDERS` (compile-time) and
`ProviderToggles` (runtime) meet here; `provider_toggles()` returns
toggles only for providers this binary has.
- **`audio-player`** — public API is feature-invariant (`Player`,
`PlayerMessage`, `SpectrumTap`, `output_device_names`); features
change only what is inside.
- **Proto / clients** — untouched.
## Risks
- **`#[cfg]` rot.** A combination nobody builds breaks silently.
Mitigated by D11's matrix in devenv scripts (and CI when there is
one).
- **Startup surprise.** A user upgrading a distro package built without
`youtube` sees `/youtube` vanish with no clue. Mitigated by D4
(startup log line, `features` command, warning when the toml names a
provider the binary lacks).
- **`flake.nix` silently shipping an empty build.** D10; it is the first
thing the plan changes after the manifests.
- **The registry refactor touching every dispatch path.** Landed as its
own commit with no feature changes, so a regression bisects cleanly.
`resolve_tracks_into`'s per-provider overrides keep working — it is a
trait method, dispatched dynamically like the rest.
- **Opus files in an opus-less build.** They fail to decode and are
skipped, with a message naming the missing feature (D6) — never a
panic.
## Open questions
None blocking. Deferred by choice: gating `[auth]` (D9, refused),
per-provider *runtime* dynamic loading (out of scope — features are
compile-time), and a `minimal` convenience feature (users compose
`--no-default-features --features fs,opus` instead).

View File

@ -3,6 +3,16 @@ name = "audio-player"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# The one axis worth a flag: Ogg-Opus decoding pulls symphonia *and*
# `symphonia-adapter-libopus`, which bundles libopus and so makes cmake +
# ninja build requirements. Everything else in this crate (HLS, the
# windowed-HTTP source, the spectrum tap) shares its dependencies with
# code that always ships, so gating it would buy nothing
# (architecture/build-features.md D6/D7/D8).
[features]
default = ["opus"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
@ -11,8 +21,8 @@ futures.workspace = true
reqwest.workspace = true reqwest.workspace = true
rodio.workspace = true rodio.workspace = true
stream-download.workspace = true stream-download.workspace = true
symphonia.workspace = true symphonia = { workspace = true, optional = true }
symphonia-adapter-libopus.workspace = true symphonia-adapter-libopus = { workspace = true, optional = true }
thiserror.workspace = true thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing.workspace = true tracing.workspace = true

View File

@ -1,4 +1,7 @@
mod hls; mod hls;
// Ogg-Opus needs symphonia's Ogg demuxer plus the bundled libopus decoder, so
// it is the crate's one feature (architecture/build-features.md D6).
#[cfg(feature = "opus")]
mod opus_source; mod opus_source;
mod player; mod player;
mod player_engine; mod player_engine;

View File

@ -12,6 +12,7 @@ use stream_download::storage::temp::TempStorageProvider;
use stream_download::{Settings, StreamDownload}; use stream_download::{Settings, StreamDownload};
use crate::hls::{HlsParams, HlsStream}; use crate::hls::{HlsParams, HlsStream};
#[cfg(feature = "opus")]
use crate::opus_source::{is_ogg_opus, OpusSource}; use crate::opus_source::{is_ogg_opus, OpusSource};
use crate::spectrum_tap::{SpectrumTap, TappingSource}; use crate::spectrum_tap::{SpectrumTap, TappingSource};
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream}; use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
@ -408,6 +409,7 @@ impl PlayerEngine {
.seek(SeekFrom::Start(0)) .seek(SeekFrom::Start(0))
.context("failed to rewind after sniffing the header")?; .context("failed to rewind after sniffing the header")?;
#[cfg(feature = "opus")]
if is_ogg_opus(&header[..n]) { if is_ogg_opus(&header[..n]) {
debug!("decoding Ogg-Opus via libopus"); debug!("decoding Ogg-Opus via libopus");
let source = OpusSource::new(reader, byte_len, seekable)?; let source = OpusSource::new(reader, byte_len, seekable)?;
@ -416,6 +418,18 @@ impl PlayerEngine {
Box::new(TappingSource::new(source, self.spectrum.clone())); Box::new(TappingSource::new(source, self.spectrum.clone()));
return Ok((tapped, duration)); return Ok((tapped, duration));
} }
// Built without the `opus` feature: rodio's decoder rejects Ogg-Opus
// (symphonia has no Opus decoder), so say why rather than let a
// "malformed stream" error stand in. Playback skips the track exactly
// as it does for any undecodable file — never a panic
// (architecture/build-features.md D6).
#[cfg(not(feature = "opus"))]
if header[..n].starts_with(b"OggS") {
return Err(anyhow!(
"cannot decode Ogg-Opus: this build has no Opus decoder (rebuild with the \
`opus` feature)"
));
}
// Symphonia probes the container length during init; without a known // Symphonia probes the container length during init; without a known
// byte length it seeks from the end, which rodio 0.22 turns into an // byte length it seeks from the end, which rodio 0.22 turns into an

View File

@ -206,6 +206,8 @@ pub enum ServerCommand {
Global(GlobalCmd), Global(GlobalCmd),
/// List audio output devices, or set `[audio] device` if one is given. /// List audio output devices, or set `[audio] device` if one is given.
AudioDevices(AudioDevicesArgs), AudioDevices(AudioDevicesArgs),
/// Print the build features this binary was compiled with.
Features,
/// Print a shell completion script. /// Print a shell completion script.
Completions(CompletionsArgs), Completions(CompletionsArgs),
} }
@ -262,6 +264,8 @@ pub enum CbdCommand {
Global(GlobalCmd), Global(GlobalCmd),
/// List audio output devices, or set `[audio] device` if one is given. /// List audio output devices, or set `[audio] device` if one is given.
AudioDevices(AudioDevicesArgs), AudioDevices(AudioDevicesArgs),
/// Print the build features this binary was compiled with.
Features,
/// Print a shell completion script. /// Print a shell completion script.
Completions(CompletionsArgs), Completions(CompletionsArgs),
} }

View File

@ -3,6 +3,13 @@ name = "cbd-tui"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# Desktop "now playing" notifications pull notify-rust and, on Linux, a
# D-Bus stack. On by default; off for a terminal-only client
# (architecture/build-features.md D1).
[features]
default = ["notifications"]
notifications = ["dep:notify-rust"]
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
cbd-cli = { workspace = true, features = ["client"] } cbd-cli = { workspace = true, features = ["client"] }
@ -12,7 +19,7 @@ clap.workspace = true
dirs.workspace = true dirs.workspace = true
toml.workspace = true toml.workspace = true
flume.workspace = true flume.workspace = true
notify-rust.workspace = true notify-rust = { workspace = true, optional = true }
ratatui.workspace = true ratatui.workspace = true
serde.workspace = true serde.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@ -1,5 +1,6 @@
use std::{ops::Div, time::Duration}; use std::{ops::Div, time::Duration};
#[cfg(feature = "notifications")]
use notify_rust::Notification; use notify_rust::Notification;
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition}; use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
@ -84,30 +85,7 @@ impl NowPlaying {
} }
pub fn update_track(&mut self, active: Option<Track>) { pub fn update_track(&mut self, active: Option<Track>) {
if let Some(track) = &active { if let Some(track) = &active {
let body = if let Some(ref album) = track.album { notify_now_playing(track);
format!(
"{} by {}\n\n{} ({})",
track.title,
track.artist,
album.title,
// FIXME: get out year and format differently if it's missing
album.release_date()
)
} else {
format!("{} by {}", track.title, track.artist,)
};
// A missing notification daemon must not crash the TUI.
// The explicit appname keeps notification-daemon rules
// (e.g. mako `app-name=` criteria) stable even if the
// binary is renamed or wrapped.
if let Err(err) = Notification::new()
.appname("crabidy")
.summary("Now playing")
.body(&body)
.show()
{
tracing::debug!("could not show desktop notification: {err}");
}
} }
self.track = active; self.track = active;
} }
@ -278,6 +256,39 @@ impl NowPlaying {
} }
} }
/// Shows the desktop "now playing" notification. A missing notification daemon
/// must not crash the TUI, so a failure is only logged. The explicit appname
/// keeps notification-daemon rules (e.g. mako `app-name=` criteria) stable even
/// if the binary is renamed or wrapped.
#[cfg(feature = "notifications")]
fn notify_now_playing(track: &Track) {
let body = if let Some(ref album) = track.album {
format!(
"{} by {}\n\n{} ({})",
track.title,
track.artist,
album.title,
// FIXME: get out year and format differently if it's missing
album.release_date()
)
} else {
format!("{} by {}", track.title, track.artist)
};
if let Err(err) = Notification::new()
.appname("crabidy")
.summary("Now playing")
.body(&body)
.show()
{
tracing::debug!("could not show desktop notification: {err}");
}
}
/// Built without the `notifications` feature: nothing to show
/// (architecture/build-features.md D1).
#[cfg(not(feature = "notifications"))]
fn notify_now_playing(_track: &Track) {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -3,12 +3,31 @@ name = "cbd"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# The bundle owns both halves, so it forwards both feature sets
# (architecture/build-features.md D1). `cargo build -p cbd
# --no-default-features --features fs,opus` is a local-files-only bundle.
[features]
default = ["all-providers", "opus", "spectrum", "web-ui", "notifications"]
all-providers = ["crabidy-server/all-providers"]
tidal = ["crabidy-server/tidal"]
youtube = ["crabidy-server/youtube"]
fyyd = ["crabidy-server/fyyd"]
abs = ["crabidy-server/abs"]
soundcloud = ["crabidy-server/soundcloud"]
jamendo = ["crabidy-server/jamendo"]
fs = ["crabidy-server/fs"]
opus = ["crabidy-server/opus"]
spectrum = ["crabidy-server/spectrum"]
web-ui = ["crabidy-server/web-ui"]
notifications = ["cbd-tui/notifications"]
[dependencies] [dependencies]
cbd-cli = { workspace = true, features = ["client"] } cbd-cli = { workspace = true, features = ["client"] }
cbd-tui.workspace = true cbd-tui = { workspace = true, default-features = false }
clap.workspace = true clap.workspace = true
crabidy-core.workspace = true crabidy-core.workspace = true
crabidy-server.workspace = true crabidy-server = { workspace = true, default-features = false }
dirs.workspace = true dirs.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tracing.workspace = true tracing.workspace = true

View File

@ -93,6 +93,7 @@ async fn run_command(
CbdCommand::Guard(args) => server_cli::guard(args).await, CbdCommand::Guard(args) => server_cli::guard(args).await,
CbdCommand::Scan(args) => server_cli::scan(args).await, CbdCommand::Scan(args) => server_cli::scan(args).await,
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device), CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
CbdCommand::Features => server_cli::features(),
CbdCommand::Auth(args) => { CbdCommand::Auth(args) => {
let password = args let password = args
.password .password

View File

@ -7,10 +7,48 @@ edition.workspace = true
name = "crabidy-server" name = "crabidy-server"
path = "src/main.rs" path = "src/main.rs"
# Everything is on by default: a plain build is the full server. Tailor a
# smaller binary with `--no-default-features --features …`
# (architecture/build-features.md D1). Feature names are exactly the
# names in `crabidy-server.toml`'s `providers` list (D3), so one
# vocabulary covers the compile-time and the runtime switch.
[features] [features]
# The embedded web client (architecture/web-client.md). On by default; default = ["all-providers", "opus", "spectrum", "web-ui"]
# disable for a headless-only binary without the bundle.
default = ["web-ui"] # Every provider this binary can mount.
all-providers = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"jamendo",
"fs",
]
# Internal marker: every provider feature enables it, so code can ask "is any
# provider compiled in?" — which Cargo features cannot express directly. Only
# a build with no providers at all (legal, see D2) leaves it off.
_any-provider = []
tidal = ["dep:tidaldy", "_any-provider"]
youtube = ["dep:ytdy", "_any-provider"]
fyyd = ["dep:fyyd", "_any-provider"]
abs = ["dep:absdy", "_any-provider"]
soundcloud = ["dep:soundclouddy", "_any-provider"]
jamendo = ["dep:jamendody", "_any-provider"]
# Local files *and* persistent state (D5): the `/fs` mount, the content
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
# persistence, and the `scan` command. Off means the server keeps its
# queue in memory only.
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest", "_any-provider"]
# Ogg-Opus decoding. Off also drops the bundled libopus C build
# (cmake + ninja) and `.opus` from what `scan` indexes (D6).
opus = ["audio-player/opus"]
# The server-side FFT that feeds clients' spectrum bars (D8).
spectrum = ["dep:realfft"]
# The embedded web client (architecture/web-client.md). Disable for a
# headless-only binary without the bundle.
web-ui = ["dep:tonic-web", "dep:include_dir"] web-ui = ["dep:tonic-web", "dep:include_dir"]
[dependencies] [dependencies]
@ -19,31 +57,33 @@ argon2.workspace = true
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true axum.workspace = true
base64.workspace = true base64.workspace = true
blake3.workspace = true blake3 = { workspace = true, optional = true }
clap.workspace = true clap.workspace = true
http.workspace = true http.workspace = true
include_dir = { workspace = true, optional = true } include_dir = { workspace = true, optional = true }
realfft.workspace = true realfft = { workspace = true, optional = true }
tonic-web = { workspace = true, optional = true } tonic-web = { workspace = true, optional = true }
tower.workspace = true tower.workspace = true
audio-player.workspace = true # default-features = false so the server's own `opus` feature decides
# whether the libopus decoder is linked (D1).
audio-player = { workspace = true, default-features = false }
# The `client` feature pulls in the gRPC executor used by the # The `client` feature pulls in the gRPC executor used by the
# library/queue/global subcommands (architecture/cli.md D1/D3). # library/queue/global subcommands (architecture/cli.md D1/D3).
cbd-cli = { workspace = true, features = ["client"] } cbd-cli = { workspace = true, features = ["client"] }
crabidy-core.workspace = true crabidy-core.workspace = true
dirs.workspace = true dirs.workspace = true
flume.workspace = true flume.workspace = true
absdy.workspace = true absdy = { workspace = true, optional = true }
fsdy.workspace = true fsdy = { workspace = true, optional = true }
fyyd.workspace = true fyyd = { workspace = true, optional = true }
jamendody.workspace = true jamendody = { workspace = true, optional = true }
futures.workspace = true futures.workspace = true
rand.workspace = true rand.workspace = true
reqwest.workspace = true reqwest = { workspace = true, optional = true }
serde.workspace = true serde.workspace = true
soundclouddy.workspace = true soundclouddy = { workspace = true, optional = true }
thiserror.workspace = true thiserror.workspace = true
tidaldy.workspace = true tidaldy = { workspace = true, optional = true }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
toml.workspace = true toml.workspace = true
tokio-stream = { workspace = true, features = ["sync"] } tokio-stream = { workspace = true, features = ["sync"] }
@ -51,7 +91,7 @@ tonic = { workspace = true, features = ["router", "transport", "codegen"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
ytdy.workspace = true ytdy = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
argon2.workspace = true argon2.workspace = true

View File

@ -10,8 +10,10 @@ use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use cbd_cli::{GuardArgs, Role, ScanArgs}; use cbd_cli::{GuardArgs, Role, ScanArgs};
#[cfg(feature = "fs")]
use tracing::warn; use tracing::warn;
#[cfg(feature = "fs")]
use crate::crabidy_store::CrabidyStore; use crate::crabidy_store::CrabidyStore;
use crate::settings::ServerSettings; use crate::settings::ServerSettings;
@ -19,8 +21,22 @@ use crate::settings::ServerSettings;
pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051"; pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051";
/// File-name extensions treated as playable audio by `scan` (lowercased). /// File-name extensions treated as playable audio by `scan` (lowercased).
/// `opus` is here only when this build can decode it, so `scan` never indexes
/// a file the player would reject (architecture/build-features.md D6).
#[cfg(feature = "fs")]
const AUDIO_EXTENSIONS: &[&str] = &[ const AUDIO_EXTENSIONS: &[&str] = &[
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wav", "webm", "wma", "aiff", "aif", "flac",
"mp3",
"m4a",
"aac",
"ogg",
#[cfg(feature = "opus")]
"opus",
"wav",
"webm",
"wma",
"aiff",
"aif",
]; ];
/// The crabidy config directory (`dirs::config_dir()/crabidy`). /// The crabidy config directory (`dirs::config_dir()/crabidy`).
@ -178,7 +194,59 @@ pub fn audio_devices(select: Option<String>) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
/// `scan` without the `fs` feature: the command exists (the clap surface is
/// feature-independent, so completions and the man page never vary) but this
/// build has no sidecar writer or content store
/// (architecture/build-features.md D5/D9).
#[cfg(not(feature = "fs"))]
pub async fn scan(_args: ScanArgs) -> Result<(), Box<dyn Error>> {
Err("this binary was built without the `fs` feature, so it cannot index a music folder".into())
}
/// The build features this binary was compiled with, in a stable order:
/// the providers it can mount ([`crate::settings::BUILT_IN_PROVIDERS`])
/// followed by the non-provider features (`opus`, `spectrum`, `web-ui`).
///
/// Used by the `features` subcommand and by the startup log line, so "why is
/// `/tidal` missing?" is answerable from the binary itself
/// (architecture/build-features.md D4).
pub fn build_features() -> Vec<&'static str> {
let mut features: Vec<&'static str> = crate::settings::BUILT_IN_PROVIDERS.to_vec();
if cfg!(feature = "opus") {
features.push("opus");
}
if cfg!(feature = "spectrum") {
features.push("spectrum");
}
if cfg!(feature = "web-ui") {
features.push("web-ui");
}
features
}
/// `features`: print what this build can do — one feature per line, so it is
/// greppable — and where the runtime `providers` list that prunes it further
/// lives (architecture/build-features.md D4).
pub fn features() -> Result<(), Box<dyn Error>> {
for feature in build_features() {
println!("{feature}");
}
let path = config_dir()
.map(|dir| {
dir.join(crate::settings::SETTINGS_FILE)
.display()
.to_string()
})
.unwrap_or_else(|_| crate::settings::SETTINGS_FILE.to_string());
eprintln!(
"\nProviders can be pruned further at runtime with the `providers` list in {path}; \
names missing above are not in this binary and cannot be enabled there."
);
Ok(())
}
/// The outcome of a `scan` walk, for a concise summary and for tests. /// The outcome of a `scan` walk, for a concise summary and for tests.
#[cfg(feature = "fs")]
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
pub struct ScanSummary { pub struct ScanSummary {
/// Sidecar `.cbd-track.toml` files written this run. /// Sidecar `.cbd-track.toml` files written this run.
@ -189,6 +257,7 @@ pub struct ScanSummary {
/// `scan <path> [--capture|--move]`: index a music folder (architecture/cli.md /// `scan <path> [--capture|--move]`: index a music folder (architecture/cli.md
/// D5). Opens the content store only when a capture/move is requested. /// D5). Opens the content store only when a capture/move is requested.
#[cfg(feature = "fs")]
pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> { pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> {
if !args.path.is_dir() { if !args.path.is_dir() {
return Err(format!("not a directory: {}", args.path.display()).into()); return Err(format!("not a directory: {}", args.path.display()).into());
@ -216,6 +285,7 @@ pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> {
/// the sidecar points at the store entry; otherwise the sidecar's playable is /// the sidecar points at the store entry; otherwise the sidecar's playable is
/// a relative [`fsdy::Playable::File`]. An existing sidecar is never /// a relative [`fsdy::Playable::File`]. An existing sidecar is never
/// clobbered. Unreadable entries are warnings, not failures. /// clobbered. Unreadable entries are warnings, not failures.
#[cfg(feature = "fs")]
pub async fn scan_dir( pub async fn scan_dir(
root: &Path, root: &Path,
store: Option<&CrabidyStore>, store: Option<&CrabidyStore>,
@ -270,6 +340,7 @@ pub async fn scan_dir(
/// Indexes one audio file. Returns `true` when a sidecar was written, `false` /// Indexes one audio file. Returns `true` when a sidecar was written, `false`
/// when one already existed (left untouched). /// when one already existed (left untouched).
#[cfg(feature = "fs")]
async fn scan_file( async fn scan_file(
dir: &Path, dir: &Path,
file: &Path, file: &Path,
@ -324,6 +395,7 @@ async fn scan_file(
} }
/// Whether `path`'s extension is a known audio extension (case-insensitive). /// Whether `path`'s extension is a known audio extension (case-insensitive).
#[cfg(feature = "fs")]
fn is_audio_file(path: &Path) -> bool { fn is_audio_file(path: &Path) -> bool {
path.extension() path.extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
@ -360,6 +432,63 @@ mod tests {
assert!(ServerSettings::load(dir.path()).is_ok()); assert!(ServerSettings::load(dir.path()).is_ok());
} }
#[test]
fn build_features_lists_providers_then_extras() {
let features = build_features();
// Providers first, in BUILT_IN_PROVIDERS order, then the extras.
let providers = crate::settings::BUILT_IN_PROVIDERS;
assert_eq!(&features[..providers.len()], providers);
let extras = &features[providers.len()..];
for extra in extras {
assert!(
["opus", "spectrum", "web-ui"].contains(extra),
"unexpected extra {extra}"
);
}
assert_eq!(extras.contains(&"opus"), cfg!(feature = "opus"));
assert_eq!(extras.contains(&"spectrum"), cfg!(feature = "spectrum"));
assert_eq!(extras.contains(&"web-ui"), cfg!(feature = "web-ui"));
}
/// `scan` must index `.opus` exactly when this build can decode it
/// (architecture/build-features.md D6) — and never change its verdict on
/// the other extensions.
#[cfg(feature = "fs")]
#[test]
fn opus_is_scannable_only_with_the_decoder() {
assert_eq!(
is_audio_file(Path::new("/music/a.opus")),
cfg!(feature = "opus")
);
assert_eq!(
is_audio_file(Path::new("/music/A.OPUS")),
cfg!(feature = "opus")
);
for name in ["a.flac", "a.mp3", "a.m4a", "a.ogg", "a.wav"] {
assert!(is_audio_file(&PathBuf::from(name)), "{name}");
}
for name in ["a.jpg", "a.cbd-track.toml", "a"] {
assert!(!is_audio_file(&PathBuf::from(name)), "{name}");
}
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_walks_past_opus_files_it_cannot_play() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("song.opus"), b"AUDIO").expect("write");
std::fs::write(dir.path().join("other.flac"), b"AUDIO").expect("write");
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
let expected = if cfg!(feature = "opus") { 2 } else { 1 };
assert_eq!(summary.written, expected);
assert_eq!(
dir.path().join("song.cbd-track.toml").is_file(),
cfg!(feature = "opus")
);
assert!(dir.path().join("other.cbd-track.toml").is_file());
}
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn scan_writes_file_playables_and_skips_existing_tomls() { async fn scan_writes_file_playables_and_skips_existing_tomls() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -389,6 +518,7 @@ mod tests {
assert_eq!(again.skipped, 1); assert_eq!(again.skipped, 1);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() { async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() {
let src = TempDir::new().expect("srcdir"); let src = TempDir::new().expect("srcdir");
@ -415,6 +545,7 @@ mod tests {
} }
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn scan_move_removes_the_source_audio() { async fn scan_move_removes_the_source_audio() {
let src = TempDir::new().expect("srcdir"); let src = TempDir::new().expect("srcdir");

View File

@ -2,14 +2,22 @@ pub mod auth;
#[cfg(feature = "web-ui")] #[cfg(feature = "web-ui")]
pub mod web; pub mod web;
// Local files and persistent state: the content store behind `/crabidy` and
// `/orphans`, bookmarks/captures, and queue persistence are all written in
// terms of `fsdy`, so they share the `fs` feature
// (architecture/build-features.md D5).
#[cfg(feature = "fs")]
pub mod capture; pub mod capture;
pub mod cli; pub mod cli;
#[cfg(feature = "fs")]
pub mod crabidy_store; pub mod crabidy_store;
#[cfg(feature = "fs")]
pub mod orphans; pub mod orphans;
pub mod playback; pub mod playback;
pub mod provider; pub mod provider;
pub mod rpc; pub mod rpc;
pub mod settings; pub mod settings;
#[cfg(feature = "spectrum")]
pub mod spectrum; pub mod spectrum;
use audio_player::PlayerMessage; use audio_player::PlayerMessage;
@ -56,6 +64,17 @@ pub async fn serve(
if authenticator.enabled() { if authenticator.enabled() {
info!("role authorization enabled"); info!("role authorization enabled");
} }
// What this binary can do at all (architecture/build-features.md D4), so
// a tailored build is self-describing in its own log.
info!(features = %cli::build_features().join(" "), "build features");
// A `providers` entry this build cannot mount is a warning, not a startup
// error: the library layer is fail-open, unlike `[auth]`.
for name in server_settings.unavailable_providers() {
warn!(
provider = name,
"crabidy-server.toml lists a provider this binary was not built with; ignoring it"
);
}
let toggles = server_settings.provider_toggles(); let toggles = server_settings.provider_toggles();
let (update_tx, _) = tokio::sync::broadcast::channel(2048); let (update_tx, _) = tokio::sync::broadcast::channel(2048);
@ -68,17 +87,21 @@ pub async fn serve(
// Queue persistence rides on the /crabidy store (its `current` folder); // Queue persistence rides on the /crabidy store (its `current` folder);
// the orchestrator built it, so playback shares the same Arc. Without a // the orchestrator built it, so playback shares the same Arc. Without a
// state/data directory it is `None` and the queue lives in memory only. // state/data directory it is `None` and the queue lives in memory only —
// as it always does in a build without the `fs` feature.
#[cfg(feature = "fs")]
let crabidy_store = orchestrator.crabidy_store(); let crabidy_store = orchestrator.crabidy_store();
let playback = playback::Playback::new( let playback = playback::Playback::new(
update_tx.clone(), update_tx.clone(),
orchestrator.provider_tx.clone(), orchestrator.provider_tx.clone(),
#[cfg(feature = "fs")]
crabidy_store, crabidy_store,
server_settings.audio.device.clone(), server_settings.audio.device.clone(),
); );
// Reload the persisted current queue before anything can observe or // Reload the persisted current queue before anything can observe or
// mutate state; never starts playback. // mutate state; never starts playback.
#[cfg(feature = "fs")]
playback.restore_current().await; playback.restore_current().await;
let playback_tx = playback.playback_tx.clone(); let playback_tx = playback.playback_tx.clone();
@ -89,6 +112,7 @@ pub async fn serve(
}); });
info!("player message forwarder started"); info!("player message forwarder started");
#[cfg(feature = "spectrum")]
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone()); spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
let crabidy_service = rpc::RpcService::new( let crabidy_service = rpc::RpcService::new(
@ -147,6 +171,7 @@ pub fn build_router(
/// subscribers, and only recomputes when the tap advanced since the /// subscribers, and only recomputes when the tap advanced since the
/// last tick (audio is flowing), emitting a single zero frame when /// last tick (audio is flowing), emitting a single zero frame when
/// playback goes idle so the bars fall rather than freeze. /// playback goes idle so the bars fall rather than freeze.
#[cfg(feature = "spectrum")]
fn spawn_spectrum_task( fn spawn_spectrum_task(
tap: std::sync::Arc<audio_player::SpectrumTap>, tap: std::sync::Arc<audio_player::SpectrumTap>,
update_tx: tokio::sync::broadcast::Sender< update_tx: tokio::sync::broadcast::Sender<
@ -906,6 +931,10 @@ pub enum ProviderCommand {
/// on `progress_tx`, ending in exactly one `finished` event (with /// on `progress_tx`, ending in exactly one `finished` event (with
/// `error` set on failure). A rejected capture answers with the error /// `error` set on failure). A rejected capture answers with the error
/// and sends no progress events. /// and sends no progress events.
///
/// Only exists with the `fs` feature: captures live in the content store,
/// which that feature brings (architecture/build-features.md D5).
#[cfg(feature = "fs")]
CaptureLibraryNode { CaptureLibraryNode {
path: String, path: String,
name: String, name: String,
@ -924,6 +953,7 @@ impl ProviderCommand {
Self::CreateLibraryNode { .. } => "create_library_node", Self::CreateLibraryNode { .. } => "create_library_node",
Self::RenameLibraryNode { .. } => "rename_library_node", Self::RenameLibraryNode { .. } => "rename_library_node",
Self::DeleteLibraryNode { .. } => "delete_library_node", Self::DeleteLibraryNode { .. } => "delete_library_node",
#[cfg(feature = "fs")]
Self::CaptureLibraryNode { .. } => "capture_library_node", Self::CaptureLibraryNode { .. } => "capture_library_node",
} }
} }
@ -989,6 +1019,10 @@ pub enum PlaybackCommand {
/// `architecture/queue-persistence.md` D6). Handled on the loop so the /// `architecture/queue-persistence.md` D6). Handled on the loop so the
/// snapshot is consistent; the disk write happens on a spawned task and /// snapshot is consistent; the disk write happens on a spawned task and
/// reports through `result_tx`. /// reports through `result_tx`.
///
/// Only exists with the `fs` feature — a saved queue is a store write
/// (architecture/build-features.md D5).
#[cfg(feature = "fs")]
SaveQueue { SaveQueue {
name: String, name: String,
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>, result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
@ -1032,6 +1066,7 @@ impl PlaybackCommand {
Self::ResolveFinished { .. } => "resolve_finished", Self::ResolveFinished { .. } => "resolve_finished",
Self::Clear { .. } => "clear", Self::Clear { .. } => "clear",
Self::SetCurrent { .. } => "set_current", Self::SetCurrent { .. } => "set_current",
#[cfg(feature = "fs")]
Self::SaveQueue { .. } => "save_queue", Self::SaveQueue { .. } => "save_queue",
Self::ToggleShuffle => "toggle_shuffle", Self::ToggleShuffle => "toggle_shuffle",
Self::ToggleRepeat => "toggle_repeat", Self::ToggleRepeat => "toggle_repeat",

View File

@ -42,6 +42,7 @@ async fn run_command(
ServerCommand::Guard(args) => cli::guard(args).await, ServerCommand::Guard(args) => cli::guard(args).await,
ServerCommand::Scan(args) => cli::scan(args).await, ServerCommand::Scan(args) => cli::scan(args).await,
ServerCommand::AudioDevices(args) => cli::audio_devices(args.device), ServerCommand::AudioDevices(args) => cli::audio_devices(args.device),
ServerCommand::Features => cli::features(),
ServerCommand::Library(cmd) => { ServerCommand::Library(cmd) => {
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await
} }

View File

@ -1,4 +1,9 @@
// Queue persistence and saved queues live in the content store, which the
// `fs` feature brings (architecture/build-features.md D5). Without it the
// queue is in memory only.
#[cfg(feature = "fs")]
use crate::capture::CaptureError; use crate::capture::CaptureError;
#[cfg(feature = "fs")]
use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot}; use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot};
use crate::{PendingResolve, QueueManager, ResolveKind}; use crate::{PendingResolve, QueueManager, ResolveKind};
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
@ -11,8 +16,12 @@ use crabidy_core::proto::crabidy::{
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; #[cfg(feature = "fs")]
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument}; use std::sync::Arc;
use std::sync::Mutex;
#[cfg(feature = "fs")]
use tracing::info;
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
pub struct Playback { pub struct Playback {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
@ -28,9 +37,11 @@ pub struct Playback {
next_op_id: AtomicU64, next_op_id: AtomicU64,
/// `None` when queue persistence is disabled (no usable state /// `None` when queue persistence is disabled (no usable state
/// directory) — the queue then lives in memory only. /// directory) — the queue then lives in memory only.
#[cfg(feature = "fs")]
store: Option<Arc<CrabidyStore>>, store: Option<Arc<CrabidyStore>>,
/// Feeds the persister task; latest snapshot wins, so the loop never /// Feeds the persister task; latest snapshot wins, so the loop never
/// waits on disk (architecture/queue-persistence.md D4). /// waits on disk (architecture/queue-persistence.md D4).
#[cfg(feature = "fs")]
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>, persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
pub player: Player, pub player: Player,
} }
@ -39,12 +50,13 @@ impl Playback {
pub fn new( pub fn new(
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
provider_tx: flume::Sender<ProviderMessage>, provider_tx: flume::Sender<ProviderMessage>,
store: Option<Arc<CrabidyStore>>, #[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>,
audio_device: Option<String>, audio_device: Option<String>,
) -> Self { ) -> Self {
let (playback_tx, playback_rx) = flume::bounded(64); let (playback_tx, playback_rx) = flume::bounded(64);
let queue = Mutex::new(QueueManager::new()); let queue = Mutex::new(QueueManager::new());
let state = Mutex::new(PlayState::Stopped); let state = Mutex::new(PlayState::Stopped);
#[cfg(feature = "fs")]
let (persist_tx, _) = tokio::sync::watch::channel(None); let (persist_tx, _) = tokio::sync::watch::channel(None);
let player = Player::new(audio_device); let player = Player::new(audio_device);
Self { Self {
@ -56,7 +68,9 @@ impl Playback {
state, state,
pending: Mutex::new(HashMap::new()), pending: Mutex::new(HashMap::new()),
next_op_id: AtomicU64::new(0), next_op_id: AtomicU64::new(0),
#[cfg(feature = "fs")]
store, store,
#[cfg(feature = "fs")]
persist_tx, persist_tx,
player, player,
} }
@ -66,6 +80,7 @@ impl Playback {
/// shuffle/repeat. Never starts playback — a restarted server stays /// shuffle/repeat. Never starts playback — a restarted server stays
/// silent. Call before [`Self::run`] so nothing observes the empty /// silent. Call before [`Self::run`] so nothing observes the empty
/// queue first. /// queue first.
#[cfg(feature = "fs")]
pub async fn restore_current(&self) { pub async fn restore_current(&self) {
let Some(store) = &self.store else { let Some(store) = &self.store else {
return; return;
@ -97,6 +112,7 @@ impl Playback {
} }
pub fn run(self) { pub fn run(self) {
#[cfg(feature = "fs")]
if let Some(store) = &self.store { if let Some(store) = &self.store {
crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe()); crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
} }
@ -257,6 +273,7 @@ impl Playback {
self.play(track).await; self.play(track).await;
} }
#[cfg(feature = "fs")]
PlaybackCommand::SaveQueue { name, result_tx } => { PlaybackCommand::SaveQueue { name, result_tx } => {
debug!(name, "saving the queue"); debug!(name, "saving the queue");
// Snapshot on the loop (single-writer discipline), write on // Snapshot on the loop (single-writer discipline), write on
@ -693,6 +710,12 @@ impl Playback {
/// when persistence is disabled. Latest snapshot wins, so calling this /// when persistence is disabled. Latest snapshot wins, so calling this
/// on every mutation is free of backpressure (the persister skips /// on every mutation is free of backpressure (the persister skips
/// writes for unchanged snapshots). /// writes for unchanged snapshots).
#[cfg(not(feature = "fs"))]
fn send_persist_snapshot(&self, _queue: &QueueManager) {}
/// Hands the queue's persistable state to the persister task; a no-op
/// when persistence is disabled.
#[cfg(feature = "fs")]
fn send_persist_snapshot(&self, queue: &QueueManager) { fn send_persist_snapshot(&self, queue: &QueueManager) {
if self.store.is_none() { if self.store.is_none() {
return; return;
@ -844,6 +867,7 @@ impl Playback {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(feature = "fs")]
use tempfile::TempDir; use tempfile::TempDir;
fn track(i: usize) -> Track { fn track(i: usize) -> Track {
@ -859,6 +883,7 @@ mod tests {
} }
} }
#[cfg(feature = "fs")]
async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> { async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> {
Arc::new( Arc::new(
CrabidyStore::open(dir.path().join("state"), dir.path().join("store")) CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
@ -867,18 +892,26 @@ mod tests {
) )
} }
fn playback_with(store: Option<Arc<CrabidyStore>>) -> Playback { fn playback_with(#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>) -> Playback {
let (update_tx, _) = tokio::sync::broadcast::channel(64); let (update_tx, _) = tokio::sync::broadcast::channel(64);
let (provider_tx, _provider_rx) = flume::bounded(16); let (provider_tx, _provider_rx) = flume::bounded(16);
Playback::new(update_tx, provider_tx, store, None) Playback::new(
update_tx,
provider_tx,
#[cfg(feature = "fs")]
store,
None,
)
} }
#[cfg(feature = "fs")]
fn fill_queue(playback: &Playback, n: usize) { fn fill_queue(playback: &Playback, n: usize) {
let tracks: Vec<Track> = (0..n).map(track).collect(); let tracks: Vec<Track> = (0..n).map(track).collect();
let mut queue = playback.queue.lock().expect("queue lock"); let mut queue = playback.queue.lock().expect("queue lock");
let _ = queue.replace_with_tracks(&tracks); let _ = queue.replace_with_tracks(&tracks);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn restore_fills_the_queue_without_starting_playback() { async fn restore_fills_the_queue_without_starting_playback() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -908,6 +941,7 @@ mod tests {
); );
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn restore_survives_an_out_of_range_position() { async fn restore_survives_an_out_of_range_position() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -927,6 +961,7 @@ mod tests {
assert_eq!(queue.current_position(), 0); assert_eq!(queue.current_position(), 0);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn save_queue_command_snapshots_the_live_queue() { async fn save_queue_command_snapshots_the_live_queue() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -960,6 +995,7 @@ mod tests {
assert_eq!(entries, 2); assert_eq!(entries, 2);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn save_queue_rejects_an_empty_queue() { async fn save_queue_rejects_an_empty_queue() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -982,7 +1018,10 @@ mod tests {
// with `None`. The marked tracks are skipped without any provider // with `None`. The marked tracks are skipped without any provider
// round trip (the provider channel is closed — a call would fail, // round trip (the provider channel is closed — a call would fail,
// not hang). // not hang).
let playback = playback_with(None); let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
let tracks: Vec<Track> = (0..3) let tracks: Vec<Track> = (0..3)
.map(|i| Track { .map(|i| Track {
is_skipped: true, is_skipped: true,
@ -1001,6 +1040,7 @@ mod tests {
assert!(urls.is_none(), "an all-skipped queue has nothing playable"); assert!(urls.is_none(), "an all-skipped queue has nothing playable");
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn queue_mutations_reach_the_persist_channel() { async fn queue_mutations_reach_the_persist_channel() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");

View File

@ -1,4 +1,6 @@
#[cfg(feature = "fs")]
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME}; use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
#[cfg(feature = "fs")]
use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT}; use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT};
use crate::settings::ProviderToggles; use crate::settings::ProviderToggles;
use crate::{ProviderCommand, ProviderMessage}; use crate::{ProviderCommand, ProviderMessage};
@ -7,7 +9,11 @@ use crabidy_core::{
proto::crabidy::{LibraryNode, LibraryNodeChild, Track}, proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
ProviderClient, ProviderError, ProviderClient, ProviderError,
}; };
use std::{fs, path::PathBuf, sync::Arc}; // Provider configs are read synchronously at startup; only provider
// registrations use it, so a build with none does not.
#[cfg(feature = "_any-provider")]
use std::fs;
use std::{path::PathBuf, sync::Arc};
use tracing::{debug, debug_span, error, instrument, warn, Instrument}; use tracing::{debug, debug_span, error, instrument, warn, Instrument};
/// One mounted provider: the library root it owns, the name the root listing /// One mounted provider: the library root it owns, the name the root listing
@ -35,6 +41,9 @@ impl std::fmt::Debug for Mount {
} }
impl Mount { impl Mount {
// A build with no provider features at all is legal (D2) and then has no
// caller for the registry primitives.
#[cfg_attr(not(feature = "_any-provider"), allow(dead_code))]
fn new(root: &'static str, name: &'static str, client: Arc<dyn ProviderClient>) -> Self { fn new(root: &'static str, name: &'static str, client: Arc<dyn ProviderClient>) -> Self {
Self { root, name, client } Self { root, name, client }
} }
@ -56,6 +65,15 @@ impl Mount {
/// ///
/// `init` may resolve values of its own (a scraped SoundCloud `client_id`, a /// `init` may resolve values of its own (a scraped SoundCloud `client_id`, a
/// refreshed token), so whatever it reports is written back. /// refreshed token), so whatever it reports is written back.
// Only the config-file providers below go through this; a build of just
// `tidal` and/or `fs` (which mount differently) has no caller.
#[cfg(any(
feature = "youtube",
feature = "fyyd",
feature = "abs",
feature = "soundcloud",
feature = "jamendo"
))]
async fn mount_from_config<C>( async fn mount_from_config<C>(
config_dir: &std::path::Path, config_dir: &std::path::Path,
config_file: &str, config_file: &str,
@ -102,7 +120,9 @@ pub struct ProviderOrchestrator {
mounts: Vec<Mount>, mounts: Vec<Mount>,
/// The single writer behind `/crabidy`: the content store and the toml /// The single writer behind `/crabidy`: the content store and the toml
/// tree. `None` disables saving/capturing (and the `/crabidy` mount goes /// tree. `None` disables saving/capturing (and the `/crabidy` mount goes
/// with it). /// with it). Only exists with the `fs` feature — the store is written in
/// terms of `fsdy` (architecture/build-features.md D5).
#[cfg(feature = "fs")]
crabidy_store: Option<Arc<CrabidyStore>>, crabidy_store: Option<Arc<CrabidyStore>>,
} }
@ -117,6 +137,7 @@ impl ProviderOrchestrator {
provider_tx, provider_tx,
provider_rx, provider_rx,
mounts, mounts,
#[cfg(feature = "fs")]
crabidy_store: None, crabidy_store: None,
} }
} }
@ -143,6 +164,7 @@ impl ProviderOrchestrator {
} }
/// The `/crabidy` store (writer), for saves and the startup restore. /// The `/crabidy` store (writer), for saves and the startup restore.
#[cfg(feature = "fs")]
pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> { pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> {
self.crabidy_store.clone() self.crabidy_store.clone()
} }
@ -219,6 +241,7 @@ impl ProviderOrchestrator {
error!("failed to send delete_library_node result: {err}"); error!("failed to send delete_library_node result: {err}");
} }
} }
#[cfg(feature = "fs")]
ProviderCommand::CaptureLibraryNode { ProviderCommand::CaptureLibraryNode {
path, path,
name, name,
@ -296,9 +319,11 @@ impl ProviderOrchestrator {
} }
// Everything that comes up gets pushed here; registration order does // Everything that comes up gets pushed here; registration order does
// not matter, `from_mounts` sorts for the root listing. // not matter, `from_mounts` sorts for the root listing.
#[cfg_attr(not(feature = "_any-provider"), allow(unused_mut))]
let mut mounts: Vec<Mount> = Vec::new(); let mut mounts: Vec<Mount> = Vec::new();
// Tidal: skipped when disabled; when enabled a broken config is still // Tidal: skipped when disabled; when enabled a broken config is still
// fatal (unlike the local providers), preserving prior behavior. // fatal (unlike the local providers), preserving prior behavior.
#[cfg(feature = "tidal")]
if enabled.tidal { if enabled.tidal {
let config_file = config_dir.join("tidaly.toml"); let config_file = config_dir.join("tidaly.toml");
debug!(config_file = %config_file.display(), "loading tidal config"); debug!(config_file = %config_file.display(), "loading tidal config");
@ -318,16 +343,26 @@ impl ProviderOrchestrator {
Arc::new(client), Arc::new(client),
)); ));
} }
// Local files and persistent state, all behind the `fs` feature
// (architecture/build-features.md D5).
//
// 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. Kept in a local as well // costs the `/fs` subtree, never the server. Kept in a local as well
// as a mount: `/orphans` needs its disk root below. // as a mount: `/orphans` needs its disk root below.
// Local files and persistent state, all behind the `fs` feature:
// the `/fs` mount, the content store, `/crabidy`, and `/orphans`
// (architecture/build-features.md D5). The block's value is the
// store the rest of the server shares.
#[cfg(feature = "fs")]
let crabidy_store = {
let fs_client = if enabled.fs { let fs_client = if enabled.fs {
let fs_config_file = config_dir.join("fsdy.toml"); let fs_config_file = config_dir.join("fsdy.toml");
debug!(config_file = %fs_config_file.display(), "loading fs config"); 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 raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
match fsdy::Client::init(&raw_fs_settings).await { match fsdy::Client::init(&raw_fs_settings).await {
Ok(client) => { Ok(client) => {
if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await { if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await
{
error!("failed to write fsdy config file: {err}"); error!("failed to write fsdy config file: {err}");
} }
Some(Arc::new(client)) Some(Arc::new(client))
@ -411,6 +446,8 @@ impl ProviderOrchestrator {
)); ));
} }
} }
crabidy_store
};
// The remaining providers all follow the same non-fatal shape — read // The remaining providers all follow the same non-fatal shape — read
// the config file, init, write back what init resolved, mount on // the config file, init, write back what init resolved, mount on
// success — so they go through one helper: // success — so they go through one helper:
@ -424,6 +461,7 @@ impl ProviderOrchestrator {
// (architecture/soundcloud-provider.md D1). // (architecture/soundcloud-provider.md D1).
// - Jamendo: needs a registered `client_id`; without one it is // - Jamendo: needs a registered `client_id`; without one it is
// disabled (architecture/jamendo-provider.md D1). // disabled (architecture/jamendo-provider.md D1).
#[cfg(feature = "youtube")]
if enabled.youtube { if enabled.youtube {
mounts.extend( mounts.extend(
mount_from_config::<ytdy::Client>( mount_from_config::<ytdy::Client>(
@ -435,6 +473,7 @@ impl ProviderOrchestrator {
.await, .await,
); );
} }
#[cfg(feature = "fyyd")]
if enabled.fyyd { if enabled.fyyd {
mounts.extend( mounts.extend(
mount_from_config::<fyyd::Client>( mount_from_config::<fyyd::Client>(
@ -446,6 +485,7 @@ impl ProviderOrchestrator {
.await, .await,
); );
} }
#[cfg(feature = "abs")]
if enabled.abs { if enabled.abs {
mounts.extend( mounts.extend(
mount_from_config::<absdy::Client>( mount_from_config::<absdy::Client>(
@ -457,6 +497,7 @@ impl ProviderOrchestrator {
.await, .await,
); );
} }
#[cfg(feature = "soundcloud")]
if enabled.soundcloud { if enabled.soundcloud {
mounts.extend( mounts.extend(
mount_from_config::<soundclouddy::Client>( mount_from_config::<soundclouddy::Client>(
@ -468,6 +509,7 @@ impl ProviderOrchestrator {
.await, .await,
); );
} }
#[cfg(feature = "jamendo")]
if enabled.jamendo { if enabled.jamendo {
mounts.extend( mounts.extend(
mount_from_config::<jamendody::Client>( mount_from_config::<jamendody::Client>(
@ -480,6 +522,7 @@ impl ProviderOrchestrator {
); );
} }
Ok(Self { Ok(Self {
#[cfg(feature = "fs")]
crabidy_store, crabidy_store,
..Self::from_mounts(mounts) ..Self::from_mounts(mounts)
}) })
@ -541,15 +584,21 @@ impl ProviderClient for ProviderOrchestrator {
debug!("serving global library root"); debug!("serving global library root");
return Ok(self.get_lib_root()); return Ok(self.get_lib_root());
} }
let mut node = self let node = self
.owner_or(path, ProviderError::MalformedPath)? .owner_or(path, ProviderError::MalformedPath)?
.get_lib_node(path) .get_lib_node(path)
.await?; .await?;
// Mark tracks already held in the content store (D3/D8) — cheap, and // Mark tracks already held in the content store (D3/D8) — cheap, and
// works while browsing any provider, not just `/crabidy`. // works while browsing any provider, not just `/crabidy`. A build
// without `fs` has no store, so nothing is ever captured.
#[cfg(feature = "fs")]
let node = {
let mut node = node;
if let Some(store) = &self.crabidy_store { if let Some(store) = &self.crabidy_store {
store.annotate_captured(&mut node).await; store.annotate_captured(&mut node).await;
} }
node
};
Ok(node) Ok(node)
} }

View File

@ -1,3 +1,4 @@
#[cfg(feature = "fs")]
use crate::capture::CaptureError; use crate::capture::CaptureError;
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
@ -18,10 +19,18 @@ use crabidy_core::proto::crabidy::{
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::pin::Pin; use std::pin::Pin;
/// The one message both store-backed RPCs answer with in a build that has no
/// content store (architecture/build-features.md D5). `Unimplemented` is the
/// honest status: the method exists on the wire, this build cannot serve it.
#[cfg(not(feature = "fs"))]
const NO_STORE: &str = "this server was built without the `fs` feature, so it has no content \
store: bookmarks, captures, and saved queues are unavailable";
/// Maps a capture/store error to a gRPC status (shared by the capture and /// Maps a capture/store error to a gRPC status (shared by the capture and
/// queue-save RPCs). Bad input → `invalid_argument`; an existing save name → /// queue-save RPCs). Bad input → `invalid_argument`; an existing save name →
/// `already_exists`; precondition failures → `failed_precondition`; /// `already_exists`; precondition failures → `failed_precondition`;
/// everything else → `internal` (logged, message not leaked). /// everything else → `internal` (logged, message not leaked).
#[cfg(feature = "fs")]
fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status { fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status {
match err { match err {
CaptureError::InvalidName(_) | CaptureError::BadSource(_) => { CaptureError::InvalidName(_) | CaptureError::BadSource(_) => {
@ -425,6 +434,14 @@ impl CrabidyService for RpcService {
&self, &self,
request: Request<CaptureLibraryNodeRequest>, request: Request<CaptureLibraryNodeRequest>,
) -> Result<Response<CaptureLibraryNodeResponse>, Status> { ) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
#[cfg(not(feature = "fs"))]
{
let _ = request;
debug!("received capture_library_node request on a store-less build");
return Err(Status::unimplemented(NO_STORE));
}
#[cfg(feature = "fs")]
{
let CaptureLibraryNodeRequest { let CaptureLibraryNodeRequest {
path, path,
name, name,
@ -468,6 +485,7 @@ impl CrabidyService for RpcService {
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")), Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
} }
} }
}
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w` /// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
/// gesture (architecture/crabidy-store.md D5). A bookmark of the live /// gesture (architecture/crabidy-store.md D5). A bookmark of the live
@ -483,6 +501,14 @@ impl CrabidyService for RpcService {
&self, &self,
request: Request<SaveQueueRequest>, request: Request<SaveQueueRequest>,
) -> Result<Response<SaveQueueResponse>, Status> { ) -> Result<Response<SaveQueueResponse>, Status> {
#[cfg(not(feature = "fs"))]
{
let _ = request;
debug!("received save_queue request on a store-less build");
Err(Status::unimplemented(NO_STORE))
}
#[cfg(feature = "fs")]
{
let name = request.into_inner().name; let name = request.into_inner().name;
tracing::Span::current().record("name", name.as_str()); tracing::Span::current().record("name", name.as_str());
debug!("received save_queue request"); debug!("received save_queue request");
@ -498,6 +524,7 @@ impl CrabidyService for RpcService {
Err(err) => Err(capture_error_status(err, "cannot save the queue")), Err(err) => Err(capture_error_status(err, "cannot save the queue")),
} }
} }
}
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn toggle_play( async fn toggle_play(

View File

@ -15,10 +15,14 @@ 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 /// Every provider name the runtime toggle understands, in the order the
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, /// default config lists them. Each name is a library root (`/tidal`,
/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the /// `/youtube`, `/fyyd`, `/abs`, `/soundcloud`, `/jamendo`, `/fs`, `/crabidy`,
/// store, so it needs `crabidy`. /// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`,
/// and both need the `fs` build feature (architecture/build-features.md D5).
///
/// This is the *vocabulary*, not what this binary can mount — see
/// [`BUILT_IN_PROVIDERS`].
pub const ALL_PROVIDERS: [&str; 9] = [ pub const ALL_PROVIDERS: [&str; 9] = [
"tidal", "tidal",
"youtube", "youtube",
@ -31,6 +35,32 @@ pub const ALL_PROVIDERS: [&str; 9] = [
"orphans", "orphans",
]; ];
/// The providers **this binary** was built with: [`ALL_PROVIDERS`] filtered by
/// the compile-time features (architecture/build-features.md D4). The default
/// config we write lists exactly these, and a `providers` entry outside this
/// set can never be mounted — it earns a startup warning
/// ([`ServerSettings::unavailable_providers`]).
pub const BUILT_IN_PROVIDERS: &[&str] = &[
#[cfg(feature = "tidal")]
"tidal",
#[cfg(feature = "youtube")]
"youtube",
#[cfg(feature = "fyyd")]
"fyyd",
#[cfg(feature = "abs")]
"abs",
#[cfg(feature = "soundcloud")]
"soundcloud",
#[cfg(feature = "jamendo")]
"jamendo",
#[cfg(feature = "fs")]
"fs",
#[cfg(feature = "fs")]
"crabidy",
#[cfg(feature = "fs")]
"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)]
@ -86,19 +116,23 @@ pub struct ProviderToggles {
} }
impl ProviderToggles { impl ProviderToggles {
/// Every provider on — the default, and what a missing/keyless config /// Every provider this binary has on — the default, and what a
/// yields. /// missing/keyless config yields. Providers built out are off: a feature
/// that is not compiled in cannot be toggled on
/// (architecture/build-features.md D4).
pub fn all() -> Self { pub fn all() -> Self {
Self { Self {
tidal: true, tidal: cfg!(feature = "tidal"),
youtube: true, youtube: cfg!(feature = "youtube"),
fyyd: true, fyyd: cfg!(feature = "fyyd"),
abs: true, abs: cfg!(feature = "abs"),
soundcloud: true, soundcloud: cfg!(feature = "soundcloud"),
jamendo: true, jamendo: cfg!(feature = "jamendo"),
fs: true, fs: cfg!(feature = "fs"),
crabidy: true, // Both live on the content store, which the `fs` feature brings
orphans: true, // (D5).
crabidy: cfg!(feature = "fs"),
orphans: cfg!(feature = "fs"),
} }
} }
} }
@ -186,14 +220,35 @@ impl ServerSettings {
Ok(settings) Ok(settings)
} }
/// Whether the named provider is enabled: every provider when the /// Whether the named provider is enabled: every *built-in* provider when
/// `providers` key is absent, otherwise only the names the list holds. /// the `providers` key is absent, otherwise only the names the list holds.
///
/// A provider this binary was not built with is never enabled, whatever
/// the config says (architecture/build-features.md D4) — the compile-time
/// set is the outer bound of the runtime one.
pub fn provider_enabled(&self, name: &str) -> bool { pub fn provider_enabled(&self, name: &str) -> bool {
self.providers BUILT_IN_PROVIDERS.contains(&name)
&& self
.providers
.as_ref() .as_ref()
.is_none_or(|list| list.iter().any(|p| p == name)) .is_none_or(|list| list.iter().any(|p| p == name))
} }
/// Names the `providers` list asks for that this binary cannot mount:
/// providers built out (`tidal` in a build without the `tidal` feature)
/// and outright unknown names (a typo). The caller logs one warning per
/// name at startup — the library layer is fail-open, so this never aborts
/// (architecture/build-features.md D4).
pub fn unavailable_providers(&self) -> Vec<&str> {
self.providers
.as_deref()
.unwrap_or_default()
.iter()
.map(String::as_str)
.filter(|name| !BUILT_IN_PROVIDERS.contains(name))
.collect()
}
/// The per-provider mount decisions for the orchestrator. `orphans` also /// The per-provider mount decisions for the orchestrator. `orphans` also
/// requires `crabidy` (it is a view over the store); that dependency is /// 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. /// enforced where the store is built, so it is not folded in here.
@ -211,10 +266,11 @@ impl ServerSettings {
} }
} }
/// Writes a default `crabidy-server.toml` — all providers enabled, no auth /// Writes a default `crabidy-server.toml` — every provider this binary has
/// — when none exists yet, so users have a full list to prune. A file that /// ([`BUILT_IN_PROVIDERS`]), no auth — when none exists yet, so users have
/// already exists (even a pruned one) is left untouched. Best-effort: the /// a full list to prune. A file that already exists (even a pruned one) is
/// caller treats a write failure as a warning, not a startup error. /// 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> { pub fn ensure_default(config_dir: &Path) -> Result<(), String> {
let file = config_dir.join(SETTINGS_FILE); let file = config_dir.join(SETTINGS_FILE);
match std::fs::metadata(&file) { match std::fs::metadata(&file) {
@ -223,7 +279,7 @@ impl ServerSettings {
Err(err) => return Err(format!("cannot check {}: {err}", file.display())), Err(err) => return Err(format!("cannot check {}: {err}", file.display())),
} }
let settings = ServerSettings { let settings = ServerSettings {
providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()), providers: Some(BUILT_IN_PROVIDERS.iter().map(|s| s.to_string()).collect()),
auth: AuthSettings::default(), auth: AuthSettings::default(),
audio: AudioSettings::default(), audio: AudioSettings::default(),
}; };
@ -370,17 +426,78 @@ mod tests {
} }
#[test] #[test]
fn an_absent_providers_key_enables_everything() { fn an_absent_providers_key_enables_every_built_in_provider() {
let settings = ServerSettings::default(); let settings = ServerSettings::default();
for provider in ALL_PROVIDERS { for provider in BUILT_IN_PROVIDERS {
assert!(settings.provider_enabled(provider), "{provider}"); assert!(settings.provider_enabled(provider), "{provider}");
} }
let toggles = settings.provider_toggles(); // …and nothing this binary was built without.
assert!( for provider in ALL_PROVIDERS {
toggles.tidal && toggles.youtube && toggles.fs && toggles.crabidy && toggles.orphans if !BUILT_IN_PROVIDERS.contains(&provider) {
); assert!(!settings.provider_enabled(provider), "{provider}");
}
}
} }
/// A provider the binary lacks cannot be turned on from the config: the
/// compile-time set bounds the runtime one
/// (architecture/build-features.md D4).
#[test]
#[cfg(not(feature = "tidal"))]
fn a_built_out_provider_cannot_be_enabled() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"tidal\"]\n").expect("write");
let settings = ServerSettings::load(dir.path()).expect("load");
assert!(!settings.provider_enabled("tidal"));
assert!(!settings.provider_toggles().tidal);
assert_eq!(settings.unavailable_providers(), vec!["tidal"]);
}
#[test]
fn unavailable_providers_reports_built_out_and_unknown_names() {
// An unknown name (a typo) is always unavailable, whatever the build.
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"providers = [\"fs\", \"tidl\", \"\"]\n",
)
.expect("write");
let settings = ServerSettings::load(dir.path()).expect("load");
let unavailable = settings.unavailable_providers();
assert!(unavailable.contains(&"tidl"), "{unavailable:?}");
assert!(unavailable.contains(&""), "{unavailable:?}");
// `fs` is only available when it was built in.
assert_eq!(
unavailable.contains(&"fs"),
!cfg!(feature = "fs"),
"{unavailable:?}"
);
// An absent key asks for nothing, so nothing is unavailable.
assert!(ServerSettings::default().unavailable_providers().is_empty());
}
#[test]
fn ensure_default_lists_only_built_in_providers() {
let dir = TempDir::new().expect("tempdir");
ServerSettings::ensure_default(dir.path()).expect("write default");
let reloaded = ServerSettings::load(dir.path()).expect("reload");
let listed = reloaded.providers.expect("providers key");
assert_eq!(listed, BUILT_IN_PROVIDERS);
// Nothing it writes can then warn at startup.
assert!(reloaded_unavailable(dir.path()).is_empty());
}
fn reloaded_unavailable(dir: &Path) -> Vec<String> {
ServerSettings::load(dir)
.expect("reload")
.unavailable_providers()
.into_iter()
.map(str::to_string)
.collect()
}
// Asserts on `fs`/`crabidy`, so it needs a build that has them.
#[cfg(feature = "fs")]
#[test] #[test]
fn an_explicit_list_enables_only_its_names() { fn an_explicit_list_enables_only_its_names() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -404,7 +521,7 @@ mod tests {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
ServerSettings::ensure_default(dir.path()).expect("write default"); ServerSettings::ensure_default(dir.path()).expect("write default");
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
for provider in ALL_PROVIDERS { for provider in BUILT_IN_PROVIDERS {
assert!(text.contains(provider), "default lists {provider}"); assert!(text.contains(provider), "default lists {provider}");
} }
assert!( assert!(
@ -414,7 +531,7 @@ mod tests {
let reloaded = ServerSettings::load(dir.path()).expect("reload"); let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!( assert_eq!(
reloaded.providers.as_ref().map(Vec::len), reloaded.providers.as_ref().map(Vec::len),
Some(ALL_PROVIDERS.len()) Some(BUILT_IN_PROVIDERS.len())
); );
// A second call must not overwrite a file the user has since pruned. // 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"); std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune");

View File

@ -35,7 +35,10 @@ let
]; ];
in in
{ {
imports = [ ./devenv-rust.nix ]; imports = [
./devenv-rust.nix
./devenv-docs.nix
];
# The wasm target for cbd-web; merges with the languages.rust # The wasm target for cbd-web; merges with the languages.rust
# settings in devenv-rust.nix. # settings in devenv-rust.nix.
@ -80,6 +83,51 @@ in
scripts.gen-cli-assets.exec = '' scripts.gen-cli-assets.exec = ''
cd "$DEVENV_ROOT" && CBD_ASSET_DIR="$DEVENV_ROOT/dist" cargo build "$@" cd "$DEVENV_ROOT" && CBD_ASSET_DIR="$DEVENV_ROOT/dist" cargo build "$@"
''; '';
# The build-feature matrix (architecture/build-features.md D11,
# quality/build-features.md G6/G7). Feature combinations are where cfg rot
# hides: a curated set catches what matters without a powerset sweep. Every
# entry must be clippy-clean under -D warnings; the two extremes (defaults
# and nothing) also run their tests.
scripts.check-features.exec = ''
set -euo pipefail
cd "$DEVENV_ROOT"
clippy() {
echo "==> clippy $*"
cargo clippy --all-targets "$@" -- -D warnings
}
test_it() {
echo "==> test $*"
cargo test "$@"
}
# The two extremes, tests included.
clippy -p crabidy-server
test_it -p crabidy-server
clippy -p crabidy-server --no-default-features
test_it -p crabidy-server --no-default-features
# Each provider on its own: nothing else may be needed to compile it.
for feature in tidal youtube fyyd abs soundcloud jamendo fs; do
clippy -p crabidy-server --no-default-features --features "$feature"
done
# Each non-provider axis dropped from an otherwise full build.
clippy -p crabidy-server --no-default-features --features all-providers,spectrum,web-ui
clippy -p crabidy-server --no-default-features --features all-providers,opus,web-ui
clippy -p crabidy-server --no-default-features --features all-providers,opus,spectrum
# The local-files appliance and the streaming box from the docs.
clippy -p crabidy-server --no-default-features --features fs,opus
clippy -p crabidy-server --no-default-features --features tidal,web-ui,opus,spectrum
# The other feature-carrying crates.
clippy -p audio-player --no-default-features
clippy -p cbd-tui --no-default-features
test_it -p cbd-tui --no-default-features
clippy -p cbd --no-default-features
clippy -p cbd --no-default-features --features fs,opus,notifications
echo "all feature combinations are clean"
'';
enterShell = ""; enterShell = "";

146
plan/build-features.md Normal file
View File

@ -0,0 +1,146 @@
# Plan — build features
Executes `architecture/build-features.md` against `quality/build-features.md`.
Ordered by dependency; each task names how it is verified. Three commits:
**(A)** the mount-registry refactor (no feature change), **(B)** the features
themselves, **(C)** packaging + docs.
Build commands run through devenv with the session-local target dir:
```sh
devenv shell -- bash -lc 'CARGO_TARGET_DIR="$(pwd)/target-claude" cargo … '
```
## A — Mount registry (behaviour-preserving refactor)
- [ ] **A1 — `Mount` + registry types.** In `provider.rs`: `struct Mount {
root: &'static str, name: &'static str, client: Arc<dyn ProviderClient> }`,
`Mount::new(root, name, Arc<impl ProviderClient>) -> Mount` (the `Arc`
coerces at the call site), and `Mount::owns(&self, path) -> bool` matching
`path == root || path.starts_with("<root>/")` — so `/fsx` is not `/fs`.
*Verifies:* `mount_owns_its_root_and_children_only` (G10).
- [ ] **A2 — `ProviderOrchestrator` holds `mounts: Vec<Mount>`.** Replace the
nine `*_client` fields (keep `crabidy_store`). Add
`from_mounts(Vec<Mount>) -> Self` (sorts the mounts crabidy-first /
orphans-last / rest alphabetical, and creates the bounded channel), used by
`build` and the tests. *Verifies:* `root_lists_mounted_providers_in_order`
(G12).
- [ ] **A3 — One dispatch helper.** `owner(&self, path) -> Option<&dyn
ProviderClient>` plus `owner_or(&self, path, err)` that warns with the same
message and returns the typed error. Delete the nine `*_owns` functions and
the nine `*_provider` accessors. *Verifies:* `unowned_paths_are_typed_errors`
(G11).
- [ ] **A4 — Rewrite the eight dispatch methods** (`is_track_path`,
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
`resolve_tracks_into`) as a single lookup each, preserving: the synthetic
root short-circuit and `annotate_captured` in `get_lib_node`,
`MalformedPath` for lookups, `NotSupported` for mutations, and the
`warn!`s. *Verifies:* `dispatch_reaches_the_owning_mount`,
`overridden_resolve_tracks_into_is_dispatched`,
`no_mounts_serves_an_empty_root` (G10, G11, G13).
- [ ] **A5 — `get_lib_root` maps the sorted mounts** to `LibraryNodeChild::new`
(no per-provider block, no re-sort). *Verifies:*
`root_lists_mounted_providers_in_order` (G12).
- [ ] **A6 — `build()` registers mounts.** Each provider's init block stays as
is; on success it pushes a `Mount` instead of assigning a field. `/crabidy`
and `/orphans` push after the store exists. *Verifies:* full test suite +
`cargo clippy -- -D warnings` (G2).
- [ ] **A7 — Commit A.** `cargo test -p crabidy-server`, clippy, fmt all clean;
no `Cargo.toml` change in this commit (G1/G2 hold trivially).
## B — The features
- [ ] **B1 — Manifests** (done in api-design; re-verify): `crabidy-server`
(`default = all-providers + opus + spectrum + web-ui`, gated deps optional),
`audio-player` (`opus`), `cbd-tui` (`notifications`), `cbd` (pass-through of
all of them). *Verifies:* G4, G5; `cargo tree -p crabidy-server` at defaults
matches the pre-change tree (G1).
- [ ] **B2 — `settings`: `BUILT_IN_PROVIDERS`, `provider_enabled` bounded by
it, `unavailable_providers()`, `ensure_default` writing only built-ins.**
Replace the `todo!()`. *Verifies:*
`an_absent_providers_key_enables_every_built_in_provider`,
`unavailable_providers_reports_built_out_and_unknown_names`,
`ensure_default_lists_only_built_in_providers`,
`a_built_out_provider_cannot_be_enabled` (G4, G25, G26).
- [ ] **B3 — Per-provider `#[cfg]` in `build()` only.** Wrap each provider's
init+registration block in `#[cfg(feature = "…")]`; the `enabled.*` toggle
read stays inside the block. Provider `use`/imports move to the same gate.
*Verifies:* the matrix (B12) compiles each provider alone; G9 by reading.
- [ ] **B4 — `fs`: gate the store half.** `#[cfg(feature = "fs")]` on the
`crabidy_store`, `capture`, and `orphans` modules (`lib.rs`), on
`ProviderOrchestrator::crabidy_store`/its field, on `Playback`'s store field
and `restore_current` and the persister, and on the `/fs`, `/crabidy`,
`/orphans` registrations. `Playback::new` keeps its arity — the store
parameter becomes `#[cfg]`-free by threading `Option<Arc<CrabidyStore>>`
only under the feature (adjust the two call sites in `lib.rs` and the
playback tests). *Verifies:* G14, G17; `--no-default-features` build (B12).
- [ ] **B5 — `fs`: RPC degradation.** Without `fs`, `CaptureLibraryNode` and
`SaveQueue` return `Status::unimplemented` naming the feature; with `fs`,
unchanged. No panic, no hang. *Verifies:* G15 by reading + a
`--no-default-features` smoke run.
- [ ] **B6 — `fs`: the `scan` command.** Gate `AUDIO_EXTENSIONS`,
`is_audio_file`, `scan`, `scan_dir`, `scan_file` and their tests behind
`fs`; without it the dispatcher in `crabidy-server/src/main.rs` and
`cbd/src/main.rs` prints "this binary was built without the `fs` feature"
and exits non-zero. The clap surface does not change. *Verifies:* G16, G9.
- [ ] **B7 — `opus` in `audio-player`.** Gate `mod opus_source` and the
`is_ogg_opus` sniff in `player_engine::build_source`; without the feature an
Ogg-Opus header produces `Err` naming the missing feature (the existing
"failed to decode" path), never a panic. *Verifies:* G18, G19 (read + the
`opus`-off matrix entry); `opus_source`'s own tests stay under the feature.
- [ ] **B8 — `opus` in `scan`.** `AUDIO_EXTENSIONS` includes `"opus"` only
under the feature. *Verifies:* `opus_is_scannable_only_with_the_decoder`,
`scan_walks_past_opus_files_it_cannot_play` (G20).
- [ ] **B9 — `spectrum`.** Gate `mod spectrum`, `spawn_spectrum_task`, and its
call; `realfft` optional. `Player::spectrum_tap()` unchanged. *Verifies:*
G21; the `spectrum`-off matrix entry.
- [ ] **B10 — `notifications` in `cbd-tui`.** Gate the `notify_rust` import and
the notify call in `app/now_playing.rs`; the config key stays accepted so an
existing `cbd-tui.toml` still parses. *Verifies:* G23; `cbd-tui
--no-default-features` build + `cargo test -p cbd-tui`.
- [ ] **B11 — `features` command.** `cli::build_features()` +
`cli::features()` (replace the `todo!()`s), wired into
`ServerCommand::Features` and `CbdCommand::Features`; plus one `info!` line
at startup listing the
build, and one `warn!` per `unavailable_providers()` entry. *Verifies:*
`build_features_lists_providers_then_extras` (G24, G25).
- [ ] **B12 — The build matrix as a devenv script.** `check-features` runs
`cargo clippy -- -D warnings` (and `cargo test` where meaningful) over:
defaults · `--no-default-features` · each of the seven providers alone ·
defaults-minus-`opus` · minus-`spectrum` · minus-`web-ui` · `-p cbd-tui
--no-default-features` · `-p audio-player --no-default-features`. Add
`cargo-hack` to devenv for ad-hoc deeper sweeps. *Verifies:* G6, G7.
- [ ] **B13 — Commit B** once the whole matrix is green and no
`todo!()`/`unimplemented!()` from the stub stage remains (G28).
## C — Packaging and docs
- [ ] **C1 — `flake.nix`.** Native build: replace the bare
`--no-default-features` with an explicit
`--no-default-features --features all-providers,opus,spectrum`; leave the
aarch64 cross build on defaults (`web-ui` on). *Verifies:* G8;
`nix build .#crabidy` if the sandbox allows, otherwise read + the equivalent
cargo invocation.
- [ ] **C2 — `docs/src/build-features.md`** — the feature table, what each one
costs to lose, the two worked examples (local-files appliance:
`--no-default-features --features fs,opus`; streaming box:
`--no-default-features --features tidal,web-ui,opus,spectrum`), the
compile-time vs runtime distinction, and the `features` command. Linked from
`SUMMARY.md`; `docs/src/config.md` cross-references it from the `providers`
key. *Verifies:* G27 + `mdbook build`.
- [ ] **C3 — `README.md`** gains a short "tailored builds" pointer.
*Verifies:* G27.
- [ ] **C4 — `plan/summary.md`** entry describing what shipped, the decisions
taken autonomously, and what stayed out (auth, HLS, spectrum tap — with the
reason). *Verifies:* dev-flow convention.
- [ ] **C5 — Commit C.**
## Deferred / not done (recorded, not silently dropped)
- Gating `[auth]`/`argon2` — refused (fail-open security risk, D9).
- Gating `hls.rs`, `windowed_http.rs`, `spectrum_tap.rs` — no dependency
payoff (D7, D8).
- A `minimal` convenience feature — users compose their own set (D1).
- CI enforcement of the matrix — there is no CI yet; `check-features` is the
manual gate.

156
quality/build-features.md Normal file
View File

@ -0,0 +1,156 @@
# Quality gates — build features
Criteria an implementation of `architecture/build-features.md` must satisfy.
Each is pass/fail by reading/reasoning or by running one command; automated
coverage lives in `crabidy-server/src/settings.rs` (built-in vs configured
providers), `crabidy-server/src/cli.rs` (`features`, `scan` extensions), and
`crabidy-server/src/provider.rs` (the mount registry: dispatch, ordering,
absent providers).
## The default build is unchanged (highest priority)
- [ ] **G1 — `cargo build` (no flags) links exactly what it links today.**
Every feature is in `default`, so the default dependency graph is identical:
`cargo tree -p crabidy-server` before and after differ in nothing but the
`optional`/feature annotations. *(gate: diff the tree.)*
- [ ] **G2 — No behaviour change at default features.** The full build mounts
the same providers in the same order (crabidy first, orphans last, rest
alphabetical), persists the queue, captures, serves the web UI, decodes Opus,
and streams spectrum frames. *(tests: the existing suites all pass
unchanged.)*
- [ ] **G3 — The proto is untouched.** No `.proto` change, no
feature-conditional RPC, no client-side feature knowledge. `cbd-tui` and
`cbd-web` build and run against any server build. *(gate: `git diff` shows
no `crabidy-core/crabidy/v1/*.proto` change.)*
## Feature hygiene
- [ ] **G4 — Every gated dependency is `optional = true` and reachable only
through its feature.** No `dep:` alias is enabled by a path other than its
own feature; no gated crate is named in a non-optional dependency line.
Applies to `tidaldy`, `ytdy`, `fyyd`, `absdy`, `soundclouddy`, `jamendody`,
`fsdy`, `blake3`, `reqwest`, `realfft`, `tonic-web`, `include_dir`
(crabidy-server), `symphonia` + `symphonia-adapter-libopus`
(audio-player), `notify-rust` (cbd-tui).
- [ ] **G5 — Feature names match the runtime vocabulary.** The provider
features are spelled exactly as the `providers` entries: `tidal`, `youtube`,
`fyyd`, `abs`, `soundcloud`, `jamendo`, `fs` (D3).
- [ ] **G6 — `--no-default-features` compiles, links, and runs** for
`crabidy-server`, `cbd`, `cbd-tui`, and `audio-player`. The provider-less
server starts, answers `Init`, serves an empty library root, and shuts down
cleanly (D2).
- [ ] **G7 — Every curated matrix entry builds warning-free** under
`cargo clippy -- -D warnings`: defaults; `--no-default-features`; each
provider alone; `fs` alone; defaults minus `opus`; minus `spectrum`; minus
`web-ui`; `cbd-tui --no-default-features`. Exposed as one devenv script
(D11). No `#[allow(dead_code)]`/`#[allow(unused)]` added to silence a
combination — unused code under a feature combination is a signal to move
the `#[cfg]`, not to allow it.
- [ ] **G8 — `flake.nix` names its features explicitly.** The native package
no longer passes a bare `--no-default-features` (which after D1 would mean
"no providers at all"); it lists what it wants. The aarch64 cross build still
ships `web-ui` (D10). *(gate: read `flake.nix`; `nix build .#crabidy` yields
a binary whose `features` output lists the providers.)*
## The mount registry
- [ ] **G9 — A provider is named in exactly two places.** Its `Cargo.toml`
feature line and its registration in `ProviderOrchestrator::build`. No
`#[cfg(feature = …)]` for a provider anywhere else in `crabidy-server`
(the `fs` feature is the documented exception — it also gates the store,
capture, orphans, persistence, and `scan`, per D5).
- [ ] **G10 — Dispatch is preserved exactly.** For every mounted provider,
`is_track_path`, `get_urls_for_track`, `get_metadata_for_track`,
`get_lib_node`, `create_lib_node`, `rename_lib_node`, `delete_lib_node`, and
`resolve_tracks_into` reach the same client for the same paths as the old
chain: a mount owns `<root>` and `<root>/…` and nothing else (`/fsx` is not
`/fs`). *(tests: `mount_owns_its_root_and_children_only`,
`dispatch_reaches_the_owning_mount`.)*
- [ ] **G11 — An unowned path degrades, never panics.** No mount → the same
errors as today: `MalformedPath` for lookups/track paths, `NotSupported` for
create/rename/delete, `false` for `is_track_path`, with the same
`warn!`. *(tests: `unowned_paths_are_typed_errors`.)*
- [ ] **G12 — The root listing offers only mounted providers, ordered
crabidy → alphabetical → orphans.** A provider built out is simply absent —
clients need no signal. *(test:
`root_lists_mounted_providers_in_order`.)*
- [ ] **G13 — `resolve_tracks_into` still dispatches dynamically.** Providers
that override the trait default (paged resolves) keep their override through
`Arc<dyn ProviderClient>`. *(test: a fake provider whose override is
observed.)*
## `fs` (local files and persistent state)
- [ ] **G14 — Without `fs`, nothing references `fsdy`, the store, captures, or
orphans.** `crabidy_store`, `capture`, and `orphans` are not compiled;
`Playback` holds no store; `restore_current` and the persister are absent or
inert.
- [ ] **G15 — Store-dependent RPCs degrade cleanly.** `CaptureLibraryNode` and
`SaveQueue` answer `Unimplemented` (never a panic, never a hang) with a
message naming the missing `fs` feature. Existing clients treat it as any
other RPC error.
- [ ] **G16 — `scan` without `fs` fails with a clear message** naming the
feature, exit code non-zero, no partial writes. The clap surface (and so
completions and the man page) is unchanged (D9).
- [ ] **G17 — A queue still works without persistence.** Queue, play, next,
shuffle, repeat behave normally; only survival across restarts is lost, and
the startup log says so once.
## `opus`
- [ ] **G18 — With `opus` off, `symphonia`, `symphonia-adapter-libopus`, and
the bundled libopus C build are gone.** `cargo tree -p audio-player
--no-default-features` shows neither crate; the build needs no `cmake`/
`ninja`. *(gate: build in an environment without cmake.)*
- [ ] **G19 — An Opus file in an `opus`-less build fails as an undecodable
file, not a panic.** `build_source` returns an `Err` whose message names the
missing feature; playback logs it and moves on, exactly as for a corrupt
file (hard rule: no panic on input).
- [ ] **G20 — `scan` indexes `.opus` if and only if the feature is on.**
`AUDIO_EXTENSIONS` contains `"opus"` under `opus` and not otherwise; the
other extensions are unaffected. *(tests: `scan_indexes_opus_with_the_feature`
/ `scan_ignores_opus_without_the_feature`.)*
## `spectrum`, `web-ui`, `notifications`
- [ ] **G21 — Without `spectrum`, `realfft` is gone** and no FFT task is
spawned; the update stream simply carries no `SpectrumFrame`. Clients
(TUI `f`, web) still run; their bars stay dark. `Player::spectrum_tap()`
keeps its signature (D8).
- [ ] **G22 — Without `web-ui`, no `tonic-web`, no `include_dir`, no embedded
bundle**, and the gRPC route still serves native HTTP/2 clients. (Existing
behaviour; only re-verified.)
- [ ] **G23 — Without `notifications`, `cbd-tui` links no `notify-rust`** and
the now-playing path compiles to nothing; every other TUI behaviour is
unchanged, including the config key (an ignored key must not break the
config parse).
## Discoverability
- [ ] **G24 — `features` prints the truth.** `crabidy-server features` and
`cbd features` list exactly the compiled providers plus the compiled extras,
one per line. *(test: `build_features_lists_providers_then_extras`.)*
- [ ] **G25 — The startup log names the build.** One `info!` line lists the
compiled features; a `providers` entry this binary cannot mount produces one
`warn!` naming it. Neither aborts startup (fail-open library layer).
*(test: `unavailable_providers_reports_built_out_and_unknown_names`.)*
- [ ] **G26 — The default `crabidy-server.toml` lists only built-in
providers**, so a pruned-by-build binary never writes a config full of names
it will warn about. *(test:
`ensure_default_lists_only_built_in_providers`.)*
- [ ] **G27 — The docs describe the flags.** `docs/src/` gains a build-features
page (in `SUMMARY.md`) with the feature table, the "what you lose" column,
and two worked examples (local-files appliance; tidal + web UI). `README`
points at it.
## Hard rules (always apply)
- [ ] **G28 — No panics on input or environment.** No `unwrap`/`expect` added
on config, path, or stream data in any gated code; a missing feature is an
error value or an absent mount, never a panic. `todo!()`/`unimplemented!()`
from the stub stage are all gone.
- [ ] **G29 — Errors stay typed at the boundaries.** `ProviderError` for
provider paths, `CaptureError`/`Status` for RPCs, `Box<dyn Error>` only in
CLI entry points. No `color-eyre` report reaches a client.
- [ ] **G30 — No secrets in the new paths.** The `features` output and the
startup lines carry feature names only — no config values, no credentials.