crabidy/architecture/build-features.md

401 lines
18 KiB
Markdown

# 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.
**D12 — `opus-bundled` splits *decoding Opus* from *vendoring libopus*
(added 2026-07-28).** D6 treated the two as one axis, so the only way to
avoid the vendored C build was to give up Opus playback. That is the
wrong trade for a packaged build: Nix, and any distribution, would rather
link the libopus it already ships than run cmake inside the sandbox.
The adapter crate already draws this line — `symphonia-adapter-libopus`'s
`bundled` feature is what pulls `opusic-sys/bundled` and with it the
`cmake` crate — so the split costs one feature and no code:
```toml
# audio-player
default = ["opus", "opus-bundled"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
opus-bundled = ["symphonia-adapter-libopus?/bundled"]
```
with the workspace dependency declared `default-features = false` and
pass-throughs up through `crabidy-server` and `cbd`. Two details are
load-bearing:
- The **weak** `?/` in `opus-bundled`. A plain `symphonia-adapter-libopus/bundled`
would enable the optional dependency itself, so `opus-bundled` alone
would silently switch Opus decoding back on — turning a "where does the
library come from" flag into a second "do we decode Opus" flag.
- `opus-bundled` stays in `default`, so a plain `cargo build` still needs
nothing installed. Opting *out* is the packager's explicit act, not the
developer's default.
Consequences for the Nix builds (D10): the native package drops `cmake`,
takes `libopus` as a `buildInput`, and leaves `opus-bundled` out of its
feature list. The aarch64 cross build keeps the vendored copy — it links
statically, and an unbundled `-lopus` would need a static aarch64 libopus
staged for the target the way `alsa-lib` is. `devenv.nix` sets
`OPUS_LIB_DIR` so the unbundled path links in the dev shell too, where the
linker knows nothing of the store.
**D13 — the native Nix package sets its own RUNPATH (added
2026-07-28).** Uncovered by D12: rustc stamps no RUNPATH, and the `-L`
that `buildInputs` contributes reaches the linker through `NIX_LDFLAGS`,
which ld-wrapper does not mirror into the binary. The installed binaries
therefore linked cleanly and then refused to *start* wherever the
libraries were not already on `LD_LIBRARY_PATH` — a latent bug for
`libasound` since the package was first written, which the added
`libopus` only made louder. `autoPatchelfHook` on the final derivation
fills the RUNPATH from `buildInputs` (plus `stdenv.cc.cc`'s `libgcc_s`,
which is the compiler's own runtime and so declared by no crate) and
fails the build on anything it cannot resolve. Verified by running each
binary under `env -i`.
## 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).