# 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>` 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`: ```rust struct Mount { root: &'static str, // "/tidal", "/fs", … name: &'static str, // root-listing title client: Arc, } ``` 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" 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" 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` plus the `Option>` 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).