351 lines
15 KiB
Markdown
351 lines
15 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.
|
|
|
|
## 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).
|