diff --git a/Cargo.toml b/Cargo.toml index cf41763..69c8eb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,13 +101,16 @@ tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } 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" } -audio-player = { path = "audio-player" } +audio-player = { path = "audio-player", default-features = false } cbd-cli = { path = "cbd-cli" } -cbd-tui = { path = "cbd-tui" } +cbd-tui = { path = "cbd-tui", default-features = false } crabidy-core = { path = "crabidy-core" } -crabidy-server = { path = "crabidy-server" } +crabidy-server = { path = "crabidy-server", default-features = false } fsdy = { path = "fsdy" } fyyd = { path = "fyyd" } jamendody = { path = "jamendody" } diff --git a/architecture/build-features.md b/architecture/build-features.md new file mode 100644 index 0000000..5b97320 --- /dev/null +++ b/architecture/build-features.md @@ -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>` 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). diff --git a/audio-player/Cargo.toml b/audio-player/Cargo.toml index fdf4b30..c01edff 100644 --- a/audio-player/Cargo.toml +++ b/audio-player/Cargo.toml @@ -3,6 +3,16 @@ name = "audio-player" version.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] anyhow.workspace = true bytes.workspace = true @@ -11,8 +21,8 @@ futures.workspace = true reqwest.workspace = true rodio.workspace = true stream-download.workspace = true -symphonia.workspace = true -symphonia-adapter-libopus.workspace = true +symphonia = { workspace = true, optional = true } +symphonia-adapter-libopus = { workspace = true, optional = true } thiserror.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } tracing.workspace = true diff --git a/audio-player/src/lib.rs b/audio-player/src/lib.rs index 79ea684..111b6b9 100644 --- a/audio-player/src/lib.rs +++ b/audio-player/src/lib.rs @@ -1,4 +1,7 @@ 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 player; mod player_engine; diff --git a/audio-player/src/player_engine.rs b/audio-player/src/player_engine.rs index ae8abe4..8745390 100644 --- a/audio-player/src/player_engine.rs +++ b/audio-player/src/player_engine.rs @@ -12,6 +12,7 @@ use stream_download::storage::temp::TempStorageProvider; use stream_download::{Settings, StreamDownload}; use crate::hls::{HlsParams, HlsStream}; +#[cfg(feature = "opus")] use crate::opus_source::{is_ogg_opus, OpusSource}; use crate::spectrum_tap::{SpectrumTap, TappingSource}; use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream}; @@ -408,6 +409,7 @@ impl PlayerEngine { .seek(SeekFrom::Start(0)) .context("failed to rewind after sniffing the header")?; + #[cfg(feature = "opus")] if is_ogg_opus(&header[..n]) { debug!("decoding Ogg-Opus via libopus"); let source = OpusSource::new(reader, byte_len, seekable)?; @@ -416,6 +418,18 @@ impl PlayerEngine { Box::new(TappingSource::new(source, self.spectrum.clone())); 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 // byte length it seeks from the end, which rodio 0.22 turns into an diff --git a/cbd-cli/src/lib.rs b/cbd-cli/src/lib.rs index 251909d..32d5f57 100644 --- a/cbd-cli/src/lib.rs +++ b/cbd-cli/src/lib.rs @@ -206,6 +206,8 @@ pub enum ServerCommand { Global(GlobalCmd), /// List audio output devices, or set `[audio] device` if one is given. AudioDevices(AudioDevicesArgs), + /// Print the build features this binary was compiled with. + Features, /// Print a shell completion script. Completions(CompletionsArgs), } @@ -262,6 +264,8 @@ pub enum CbdCommand { Global(GlobalCmd), /// List audio output devices, or set `[audio] device` if one is given. AudioDevices(AudioDevicesArgs), + /// Print the build features this binary was compiled with. + Features, /// Print a shell completion script. Completions(CompletionsArgs), } diff --git a/cbd-tui/Cargo.toml b/cbd-tui/Cargo.toml index 56087fb..8bcafcb 100644 --- a/cbd-tui/Cargo.toml +++ b/cbd-tui/Cargo.toml @@ -3,6 +3,13 @@ name = "cbd-tui" version.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] base64.workspace = true cbd-cli = { workspace = true, features = ["client"] } @@ -12,7 +19,7 @@ clap.workspace = true dirs.workspace = true toml.workspace = true flume.workspace = true -notify-rust.workspace = true +notify-rust = { workspace = true, optional = true } ratatui.workspace = true serde.workspace = true tokio = { workspace = true, features = ["full"] } diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 55989a3..8cea081 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -1,5 +1,6 @@ use std::{ops::Div, time::Duration}; +#[cfg(feature = "notifications")] use notify_rust::Notification; use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition}; @@ -84,30 +85,7 @@ impl NowPlaying { } pub fn update_track(&mut self, active: Option) { if let Some(track) = &active { - 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,) - }; - // 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}"); - } + notify_now_playing(track); } 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)] mod tests { use super::*; diff --git a/cbd/Cargo.toml b/cbd/Cargo.toml index 61d696f..7a6fe4f 100644 --- a/cbd/Cargo.toml +++ b/cbd/Cargo.toml @@ -3,12 +3,31 @@ name = "cbd" version.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] cbd-cli = { workspace = true, features = ["client"] } -cbd-tui.workspace = true +cbd-tui = { workspace = true, default-features = false } clap.workspace = true crabidy-core.workspace = true -crabidy-server.workspace = true +crabidy-server = { workspace = true, default-features = false } dirs.workspace = true tokio = { workspace = true, features = ["full"] } tracing.workspace = true diff --git a/cbd/src/main.rs b/cbd/src/main.rs index de813eb..534fb52 100644 --- a/cbd/src/main.rs +++ b/cbd/src/main.rs @@ -93,6 +93,7 @@ async fn run_command( CbdCommand::Guard(args) => server_cli::guard(args).await, CbdCommand::Scan(args) => server_cli::scan(args).await, CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device), + CbdCommand::Features => server_cli::features(), CbdCommand::Auth(args) => { let password = args .password diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index fadc020..a516c27 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -7,10 +7,48 @@ edition.workspace = true name = "crabidy-server" 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] -# The embedded web client (architecture/web-client.md). On by default; -# disable for a headless-only binary without the bundle. -default = ["web-ui"] +default = ["all-providers", "opus", "spectrum", "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"] [dependencies] @@ -19,31 +57,33 @@ argon2.workspace = true async-trait.workspace = true axum.workspace = true base64.workspace = true -blake3.workspace = true +blake3 = { workspace = true, optional = true } clap.workspace = true http.workspace = true include_dir = { workspace = true, optional = true } -realfft.workspace = true +realfft = { workspace = true, optional = true } tonic-web = { workspace = true, optional = 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 # library/queue/global subcommands (architecture/cli.md D1/D3). cbd-cli = { workspace = true, features = ["client"] } crabidy-core.workspace = true dirs.workspace = true flume.workspace = true -absdy.workspace = true -fsdy.workspace = true -fyyd.workspace = true -jamendody.workspace = true +absdy = { workspace = true, optional = true } +fsdy = { workspace = true, optional = true } +fyyd = { workspace = true, optional = true } +jamendody = { workspace = true, optional = true } futures.workspace = true rand.workspace = true -reqwest.workspace = true +reqwest = { workspace = true, optional = true } serde.workspace = true -soundclouddy.workspace = true +soundclouddy = { workspace = true, optional = true } thiserror.workspace = true -tidaldy.workspace = true +tidaldy = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"] } toml.workspace = true tokio-stream = { workspace = true, features = ["sync"] } @@ -51,7 +91,7 @@ tonic = { workspace = true, features = ["router", "transport", "codegen"] } tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true -ytdy.workspace = true +ytdy = { workspace = true, optional = true } [dev-dependencies] argon2.workspace = true diff --git a/crabidy-server/src/cli.rs b/crabidy-server/src/cli.rs index db58580..9a033a2 100644 --- a/crabidy-server/src/cli.rs +++ b/crabidy-server/src/cli.rs @@ -10,8 +10,10 @@ use std::io::{IsTerminal, Read}; use std::path::{Path, PathBuf}; use cbd_cli::{GuardArgs, Role, ScanArgs}; +#[cfg(feature = "fs")] use tracing::warn; +#[cfg(feature = "fs")] use crate::crabidy_store::CrabidyStore; use crate::settings::ServerSettings; @@ -19,8 +21,22 @@ use crate::settings::ServerSettings; pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051"; /// 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] = &[ - "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`). @@ -178,7 +194,59 @@ pub fn audio_devices(select: Option) -> Result<(), Box> { 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> { + 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> { + 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. +#[cfg(feature = "fs")] #[derive(Debug, Default, PartialEq, Eq)] pub struct ScanSummary { /// Sidecar `.cbd-track.toml` files written this run. @@ -189,6 +257,7 @@ pub struct ScanSummary { /// `scan [--capture|--move]`: index a music folder (architecture/cli.md /// D5). Opens the content store only when a capture/move is requested. +#[cfg(feature = "fs")] pub async fn scan(args: ScanArgs) -> Result<(), Box> { if !args.path.is_dir() { return Err(format!("not a directory: {}", args.path.display()).into()); @@ -216,6 +285,7 @@ pub async fn scan(args: ScanArgs) -> Result<(), Box> { /// the sidecar points at the store entry; otherwise the sidecar's playable is /// a relative [`fsdy::Playable::File`]. An existing sidecar is never /// clobbered. Unreadable entries are warnings, not failures. +#[cfg(feature = "fs")] pub async fn scan_dir( root: &Path, store: Option<&CrabidyStore>, @@ -270,6 +340,7 @@ pub async fn scan_dir( /// Indexes one audio file. Returns `true` when a sidecar was written, `false` /// when one already existed (left untouched). +#[cfg(feature = "fs")] async fn scan_file( dir: &Path, file: &Path, @@ -324,6 +395,7 @@ async fn scan_file( } /// Whether `path`'s extension is a known audio extension (case-insensitive). +#[cfg(feature = "fs")] fn is_audio_file(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) @@ -360,6 +432,63 @@ mod tests { 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] async fn scan_writes_file_playables_and_skips_existing_tomls() { let dir = TempDir::new().expect("tempdir"); @@ -389,6 +518,7 @@ mod tests { assert_eq!(again.skipped, 1); } + #[cfg(feature = "fs")] #[tokio::test] async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() { let src = TempDir::new().expect("srcdir"); @@ -415,6 +545,7 @@ mod tests { } } + #[cfg(feature = "fs")] #[tokio::test] async fn scan_move_removes_the_source_audio() { let src = TempDir::new().expect("srcdir"); diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index a058486..2104ee7 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -2,14 +2,22 @@ pub mod auth; #[cfg(feature = "web-ui")] 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 cli; +#[cfg(feature = "fs")] pub mod crabidy_store; +#[cfg(feature = "fs")] pub mod orphans; pub mod playback; pub mod provider; pub mod rpc; pub mod settings; +#[cfg(feature = "spectrum")] pub mod spectrum; use audio_player::PlayerMessage; @@ -56,6 +64,17 @@ pub async fn serve( if authenticator.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 (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); // 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 playback = playback::Playback::new( update_tx.clone(), orchestrator.provider_tx.clone(), + #[cfg(feature = "fs")] crabidy_store, server_settings.audio.device.clone(), ); // Reload the persisted current queue before anything can observe or // mutate state; never starts playback. + #[cfg(feature = "fs")] playback.restore_current().await; let playback_tx = playback.playback_tx.clone(); @@ -89,6 +112,7 @@ pub async fn serve( }); info!("player message forwarder started"); + #[cfg(feature = "spectrum")] spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone()); let crabidy_service = rpc::RpcService::new( @@ -147,6 +171,7 @@ pub fn build_router( /// subscribers, and only recomputes when the tap advanced since the /// last tick (audio is flowing), emitting a single zero frame when /// playback goes idle so the bars fall rather than freeze. +#[cfg(feature = "spectrum")] fn spawn_spectrum_task( tap: std::sync::Arc, update_tx: tokio::sync::broadcast::Sender< @@ -906,6 +931,10 @@ pub enum ProviderCommand { /// on `progress_tx`, ending in exactly one `finished` event (with /// `error` set on failure). A rejected capture answers with the error /// 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 { path: String, name: String, @@ -924,6 +953,7 @@ impl ProviderCommand { Self::CreateLibraryNode { .. } => "create_library_node", Self::RenameLibraryNode { .. } => "rename_library_node", Self::DeleteLibraryNode { .. } => "delete_library_node", + #[cfg(feature = "fs")] Self::CaptureLibraryNode { .. } => "capture_library_node", } } @@ -989,6 +1019,10 @@ pub enum PlaybackCommand { /// `architecture/queue-persistence.md` D6). Handled on the loop so the /// snapshot is consistent; the disk write happens on a spawned task and /// 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 { name: String, result_tx: flume::Sender>, @@ -1032,6 +1066,7 @@ impl PlaybackCommand { Self::ResolveFinished { .. } => "resolve_finished", Self::Clear { .. } => "clear", Self::SetCurrent { .. } => "set_current", + #[cfg(feature = "fs")] Self::SaveQueue { .. } => "save_queue", Self::ToggleShuffle => "toggle_shuffle", Self::ToggleRepeat => "toggle_repeat", diff --git a/crabidy-server/src/main.rs b/crabidy-server/src/main.rs index 283a85f..0639eb9 100644 --- a/crabidy-server/src/main.rs +++ b/crabidy-server/src/main.rs @@ -42,6 +42,7 @@ async fn run_command( ServerCommand::Guard(args) => cli::guard(args).await, ServerCommand::Scan(args) => cli::scan(args).await, ServerCommand::AudioDevices(args) => cli::audio_devices(args.device), + ServerCommand::Features => cli::features(), ServerCommand::Library(cmd) => { cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await } diff --git a/crabidy-server/src/playback.rs b/crabidy-server/src/playback.rs index 0081b5b..9b2e636 100644 --- a/crabidy-server/src/playback.rs +++ b/crabidy-server/src/playback.rs @@ -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; +#[cfg(feature = "fs")] use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot}; use crate::{PendingResolve, QueueManager, ResolveKind}; use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; @@ -11,8 +16,12 @@ use crabidy_core::proto::crabidy::{ use crabidy_core::ProviderError; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument}; +#[cfg(feature = "fs")] +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 { update_tx: tokio::sync::broadcast::Sender, @@ -28,9 +37,11 @@ pub struct Playback { next_op_id: AtomicU64, /// `None` when queue persistence is disabled (no usable state /// directory) — the queue then lives in memory only. + #[cfg(feature = "fs")] store: Option>, /// Feeds the persister task; latest snapshot wins, so the loop never /// waits on disk (architecture/queue-persistence.md D4). + #[cfg(feature = "fs")] persist_tx: tokio::sync::watch::Sender>, pub player: Player, } @@ -39,12 +50,13 @@ impl Playback { pub fn new( update_tx: tokio::sync::broadcast::Sender, provider_tx: flume::Sender, - store: Option>, + #[cfg(feature = "fs")] store: Option>, audio_device: Option, ) -> Self { let (playback_tx, playback_rx) = flume::bounded(64); let queue = Mutex::new(QueueManager::new()); let state = Mutex::new(PlayState::Stopped); + #[cfg(feature = "fs")] let (persist_tx, _) = tokio::sync::watch::channel(None); let player = Player::new(audio_device); Self { @@ -56,7 +68,9 @@ impl Playback { state, pending: Mutex::new(HashMap::new()), next_op_id: AtomicU64::new(0), + #[cfg(feature = "fs")] store, + #[cfg(feature = "fs")] persist_tx, player, } @@ -66,6 +80,7 @@ impl Playback { /// shuffle/repeat. Never starts playback — a restarted server stays /// silent. Call before [`Self::run`] so nothing observes the empty /// queue first. + #[cfg(feature = "fs")] pub async fn restore_current(&self) { let Some(store) = &self.store else { return; @@ -97,6 +112,7 @@ impl Playback { } pub fn run(self) { + #[cfg(feature = "fs")] if let Some(store) = &self.store { crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe()); } @@ -257,6 +273,7 @@ impl Playback { self.play(track).await; } + #[cfg(feature = "fs")] PlaybackCommand::SaveQueue { name, result_tx } => { debug!(name, "saving the queue"); // 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 /// on every mutation is free of backpressure (the persister skips /// 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) { if self.store.is_none() { return; @@ -844,6 +867,7 @@ impl Playback { #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "fs")] use tempfile::TempDir; fn track(i: usize) -> Track { @@ -859,6 +883,7 @@ mod tests { } } + #[cfg(feature = "fs")] async fn store_in(dir: &TempDir) -> Arc { Arc::new( CrabidyStore::open(dir.path().join("state"), dir.path().join("store")) @@ -867,18 +892,26 @@ mod tests { ) } - fn playback_with(store: Option>) -> Playback { + fn playback_with(#[cfg(feature = "fs")] store: Option>) -> Playback { let (update_tx, _) = tokio::sync::broadcast::channel(64); 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) { let tracks: Vec = (0..n).map(track).collect(); let mut queue = playback.queue.lock().expect("queue lock"); let _ = queue.replace_with_tracks(&tracks); } + #[cfg(feature = "fs")] #[tokio::test] async fn restore_fills_the_queue_without_starting_playback() { let dir = TempDir::new().expect("tempdir"); @@ -908,6 +941,7 @@ mod tests { ); } + #[cfg(feature = "fs")] #[tokio::test] async fn restore_survives_an_out_of_range_position() { let dir = TempDir::new().expect("tempdir"); @@ -927,6 +961,7 @@ mod tests { assert_eq!(queue.current_position(), 0); } + #[cfg(feature = "fs")] #[tokio::test] async fn save_queue_command_snapshots_the_live_queue() { let dir = TempDir::new().expect("tempdir"); @@ -960,6 +995,7 @@ mod tests { assert_eq!(entries, 2); } + #[cfg(feature = "fs")] #[tokio::test] async fn save_queue_rejects_an_empty_queue() { let dir = TempDir::new().expect("tempdir"); @@ -982,7 +1018,10 @@ mod tests { // with `None`. The marked tracks are skipped without any provider // round trip (the provider channel is closed — a call would fail, // not hang). - let playback = playback_with(None); + let playback = playback_with( + #[cfg(feature = "fs")] + None, + ); let tracks: Vec = (0..3) .map(|i| Track { is_skipped: true, @@ -1001,6 +1040,7 @@ mod tests { assert!(urls.is_none(), "an all-skipped queue has nothing playable"); } + #[cfg(feature = "fs")] #[tokio::test] async fn queue_mutations_reach_the_persist_channel() { let dir = TempDir::new().expect("tempdir"); diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 553ee58..09cab1e 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -1,4 +1,6 @@ +#[cfg(feature = "fs")] use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME}; +#[cfg(feature = "fs")] use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT}; use crate::settings::ProviderToggles; use crate::{ProviderCommand, ProviderMessage}; @@ -7,7 +9,11 @@ use crabidy_core::{ proto::crabidy::{LibraryNode, LibraryNodeChild, Track}, 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}; /// 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 { + // 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) -> Self { Self { root, name, client } } @@ -56,6 +65,15 @@ impl Mount { /// /// `init` may resolve values of its own (a scraped SoundCloud `client_id`, a /// 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( config_dir: &std::path::Path, config_file: &str, @@ -102,7 +120,9 @@ pub struct ProviderOrchestrator { mounts: Vec, /// The single writer behind `/crabidy`: the content store and the toml /// 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>, } @@ -117,6 +137,7 @@ impl ProviderOrchestrator { provider_tx, provider_rx, mounts, + #[cfg(feature = "fs")] crabidy_store: None, } } @@ -143,6 +164,7 @@ impl ProviderOrchestrator { } /// The `/crabidy` store (writer), for saves and the startup restore. + #[cfg(feature = "fs")] pub fn crabidy_store(&self) -> Option> { self.crabidy_store.clone() } @@ -219,6 +241,7 @@ impl ProviderOrchestrator { error!("failed to send delete_library_node result: {err}"); } } + #[cfg(feature = "fs")] ProviderCommand::CaptureLibraryNode { path, name, @@ -296,9 +319,11 @@ impl ProviderOrchestrator { } // Everything that comes up gets pushed here; registration order does // not matter, `from_mounts` sorts for the root listing. + #[cfg_attr(not(feature = "_any-provider"), allow(unused_mut))] let mut mounts: Vec = Vec::new(); // Tidal: skipped when disabled; when enabled a broken config is still // fatal (unlike the local providers), preserving prior behavior. + #[cfg(feature = "tidal")] if enabled.tidal { let config_file = config_dir.join("tidaly.toml"); debug!(config_file = %config_file.display(), "loading tidal config"); @@ -318,99 +343,111 @@ impl ProviderOrchestrator { 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 // costs the `/fs` subtree, never the server. Kept in a local as well // as a mount: `/orphans` needs its disk root below. - let fs_client = if enabled.fs { - let fs_config_file = config_dir.join("fsdy.toml"); - debug!(config_file = %fs_config_file.display(), "loading fs config"); - let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default(); - match fsdy::Client::init(&raw_fs_settings).await { - Ok(client) => { - if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await { - error!("failed to write fsdy config file: {err}"); + // 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_config_file = config_dir.join("fsdy.toml"); + debug!(config_file = %fs_config_file.display(), "loading fs config"); + let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default(); + match fsdy::Client::init(&raw_fs_settings).await { + Ok(client) => { + if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await + { + error!("failed to write fsdy config file: {err}"); + } + Some(Arc::new(client)) } - Some(Arc::new(client)) - } - Err(err) => { - warn!("filesystem provider disabled: {err}"); - None - } - } - } else { - None - }; - if let Some(client) = &fs_client { - mounts.push(Mount::new( - fsdy::PROVIDER_ROOT, - "fs", - Arc::clone(client) as Arc, - )); - } - // The single `/crabidy` provider: one content store + toml tree - // (architecture/crabidy-store.md). The store owns both roots (state - // tree + data store) and is the sole writer; the mounted `fsdy` - // client reads the tree and resolves store playables. Non-fatal like - // the other local providers — no state/data dir just drops `/crabidy`. - let crabidy_store = if enabled.crabidy { - match ( - CrabidyStore::default_tree_root(), - CrabidyStore::default_store_root(), - ) { - (Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await { - Ok(store) => Some(Arc::new(store)), Err(err) => { - warn!("crabidy library disabled: {err}"); + warn!("filesystem provider disabled: {err}"); None } - }, - _ => { - warn!("crabidy library disabled: no state/data directory"); - None - } - } - } else { - None - }; - // Saved queues/bookmarks are renamable and deletable; the - // auto-persisted `current` stays untouchable. The whole tree is - // downloadable (`W` captures a save) and deletable (deleting a toml - // never touches the shared store, D7). Store playables resolve - // against the data store root. - if let Some(store) = &crabidy_store { - match fsdy::Client::new(CRABIDY_PROVIDER_ROOT, store.tree_dir().to_path_buf()) { - Ok(client) => mounts.push(Mount::new( - CRABIDY_PROVIDER_ROOT, - "crabidy", - Arc::new( - client - .with_editable_top_level(&[CURRENT_NAME]) - .with_downloadable_nodes() - .with_deletable_tree() - .with_store_root(store.store_dir().to_path_buf()), - ), - )), - Err(err) => warn!("crabidy library disabled: {err}"), - } - } - // `/orphans`: a view over the store, so it needs both its own toggle - // and a live store. Its reference roots are the disk roots of the - // mounted file providers — the `/crabidy` toml tree and (when enabled) - // the `/fs` root — the only places a `Playable::Store` link can live - // (architecture/orphans.md). - if enabled.orphans { - if let Some(store) = &crabidy_store { - let mut ref_roots = vec![store.tree_dir().to_path_buf()]; - if let Some(fs) = &fs_client { - ref_roots.push(fs.disk_root().to_path_buf()); } + } else { + None + }; + if let Some(client) = &fs_client { mounts.push(Mount::new( - ORPHANS_PROVIDER_ROOT, - "orphans", - Arc::new(OrphansProvider::new(Arc::clone(store), ref_roots)), + fsdy::PROVIDER_ROOT, + "fs", + Arc::clone(client) as Arc, )); } - } + // The single `/crabidy` provider: one content store + toml tree + // (architecture/crabidy-store.md). The store owns both roots (state + // tree + data store) and is the sole writer; the mounted `fsdy` + // client reads the tree and resolves store playables. Non-fatal like + // the other local providers — no state/data dir just drops `/crabidy`. + let crabidy_store = if enabled.crabidy { + match ( + CrabidyStore::default_tree_root(), + CrabidyStore::default_store_root(), + ) { + (Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await { + Ok(store) => Some(Arc::new(store)), + Err(err) => { + warn!("crabidy library disabled: {err}"); + None + } + }, + _ => { + warn!("crabidy library disabled: no state/data directory"); + None + } + } + } else { + None + }; + // Saved queues/bookmarks are renamable and deletable; the + // auto-persisted `current` stays untouchable. The whole tree is + // downloadable (`W` captures a save) and deletable (deleting a toml + // never touches the shared store, D7). Store playables resolve + // against the data store root. + if let Some(store) = &crabidy_store { + match fsdy::Client::new(CRABIDY_PROVIDER_ROOT, store.tree_dir().to_path_buf()) { + Ok(client) => mounts.push(Mount::new( + CRABIDY_PROVIDER_ROOT, + "crabidy", + Arc::new( + client + .with_editable_top_level(&[CURRENT_NAME]) + .with_downloadable_nodes() + .with_deletable_tree() + .with_store_root(store.store_dir().to_path_buf()), + ), + )), + Err(err) => warn!("crabidy library disabled: {err}"), + } + } + // `/orphans`: a view over the store, so it needs both its own toggle + // and a live store. Its reference roots are the disk roots of the + // mounted file providers — the `/crabidy` toml tree and (when enabled) + // the `/fs` root — the only places a `Playable::Store` link can live + // (architecture/orphans.md). + if enabled.orphans { + if let Some(store) = &crabidy_store { + let mut ref_roots = vec![store.tree_dir().to_path_buf()]; + if let Some(fs) = &fs_client { + ref_roots.push(fs.disk_root().to_path_buf()); + } + mounts.push(Mount::new( + ORPHANS_PROVIDER_ROOT, + "orphans", + Arc::new(OrphansProvider::new(Arc::clone(store), ref_roots)), + )); + } + } + crabidy_store + }; // The remaining providers all follow the same non-fatal shape — read // the config file, init, write back what init resolved, mount on // success — so they go through one helper: @@ -424,6 +461,7 @@ impl ProviderOrchestrator { // (architecture/soundcloud-provider.md D1). // - Jamendo: needs a registered `client_id`; without one it is // disabled (architecture/jamendo-provider.md D1). + #[cfg(feature = "youtube")] if enabled.youtube { mounts.extend( mount_from_config::( @@ -435,6 +473,7 @@ impl ProviderOrchestrator { .await, ); } + #[cfg(feature = "fyyd")] if enabled.fyyd { mounts.extend( mount_from_config::( @@ -446,6 +485,7 @@ impl ProviderOrchestrator { .await, ); } + #[cfg(feature = "abs")] if enabled.abs { mounts.extend( mount_from_config::( @@ -457,6 +497,7 @@ impl ProviderOrchestrator { .await, ); } + #[cfg(feature = "soundcloud")] if enabled.soundcloud { mounts.extend( mount_from_config::( @@ -468,6 +509,7 @@ impl ProviderOrchestrator { .await, ); } + #[cfg(feature = "jamendo")] if enabled.jamendo { mounts.extend( mount_from_config::( @@ -480,6 +522,7 @@ impl ProviderOrchestrator { ); } Ok(Self { + #[cfg(feature = "fs")] crabidy_store, ..Self::from_mounts(mounts) }) @@ -541,15 +584,21 @@ impl ProviderClient for ProviderOrchestrator { debug!("serving global library root"); return Ok(self.get_lib_root()); } - let mut node = self + let node = self .owner_or(path, ProviderError::MalformedPath)? .get_lib_node(path) .await?; // Mark tracks already held in the content store (D3/D8) — cheap, and - // works while browsing any provider, not just `/crabidy`. - if let Some(store) = &self.crabidy_store { - store.annotate_captured(&mut node).await; - } + // 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 { + store.annotate_captured(&mut node).await; + } + node + }; Ok(node) } diff --git a/crabidy-server/src/rpc.rs b/crabidy-server/src/rpc.rs index 3d433ce..9644737 100644 --- a/crabidy-server/src/rpc.rs +++ b/crabidy-server/src/rpc.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "fs")] use crate::capture::CaptureError; use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crabidy_core::proto::crabidy::{ @@ -18,10 +19,18 @@ use crabidy_core::proto::crabidy::{ use crabidy_core::ProviderError; 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 /// queue-save RPCs). Bad input → `invalid_argument`; an existing save name → /// `already_exists`; precondition failures → `failed_precondition`; /// everything else → `internal` (logged, message not leaked). +#[cfg(feature = "fs")] fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status { match err { CaptureError::InvalidName(_) | CaptureError::BadSource(_) => { @@ -425,47 +434,56 @@ impl CrabidyService for RpcService { &self, request: Request, ) -> Result, Status> { - let CaptureLibraryNodeRequest { - path, - name, - download, - } = request.into_inner(); - tracing::Span::current().record("path", path.as_str()); - tracing::Span::current().record("name", name.as_str()); - tracing::Span::current().record("download", download); - debug!("received capture_library_node request"); - // The walk's progress events fan out to every connected client via - // the update broadcast; the forwarder dies with the walk's terminal - // event (the provider drops the sender). - let (progress_tx, progress_rx) = flume::bounded(64); - let update_tx = self.update_tx.clone(); - tokio::spawn(async move { - while let Ok(progress) = progress_rx.recv_async().await { - // No subscribers is normal (e.g. no client connected). - let _ = update_tx.send(StreamUpdate::CaptureProgress(progress)); - } - }); - let (result_tx, result_rx) = flume::bounded(1); - self.provider_tx - .send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode { + #[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 { path, name, download, - progress_tx, - result_tx, - })) - .await - .map_err(|err| { - error!("provider channel closed: {err}"); - Status::internal("provider unavailable") + } = request.into_inner(); + tracing::Span::current().record("path", path.as_str()); + tracing::Span::current().record("name", name.as_str()); + tracing::Span::current().record("download", download); + debug!("received capture_library_node request"); + // The walk's progress events fan out to every connected client via + // the update broadcast; the forwarder dies with the walk's terminal + // event (the provider drops the sender). + let (progress_tx, progress_rx) = flume::bounded(64); + let update_tx = self.update_tx.clone(); + tokio::spawn(async move { + while let Ok(progress) = progress_rx.recv_async().await { + // No subscribers is normal (e.g. no client connected). + let _ = update_tx.send(StreamUpdate::CaptureProgress(progress)); + } + }); + let (result_tx, result_rx) = flume::bounded(1); + self.provider_tx + .send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode { + path, + name, + download, + progress_tx, + result_tx, + })) + .await + .map_err(|err| { + error!("provider channel closed: {err}"); + Status::internal("provider unavailable") + })?; + let result = result_rx.recv_async().await.map_err(|err| { + error!("no reply from provider: {err}"); + Status::internal("provider did not reply") })?; - let result = result_rx.recv_async().await.map_err(|err| { - error!("no reply from provider: {err}"); - Status::internal("provider did not reply") - })?; - match result { - Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})), - Err(err) => Err(capture_error_status(err, "cannot capture the subtree")), + match result { + Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})), + Err(err) => Err(capture_error_status(err, "cannot capture the subtree")), + } } } @@ -483,19 +501,28 @@ impl CrabidyService for RpcService { &self, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - tracing::Span::current().record("name", name.as_str()); - debug!("received save_queue request"); - let (result_tx, result_rx) = flume::bounded(1); - self.send_playback(PlaybackCommand::SaveQueue { name, result_tx }) - .await?; - let result = result_rx.recv_async().await.map_err(|err| { - error!("no reply from playback loop: {err}"); - Status::internal("playback loop did not reply") - })?; - match result { - Ok(()) => Ok(Response::new(SaveQueueResponse {})), - Err(err) => Err(capture_error_status(err, "cannot save the queue")), + #[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; + tracing::Span::current().record("name", name.as_str()); + debug!("received save_queue request"); + let (result_tx, result_rx) = flume::bounded(1); + self.send_playback(PlaybackCommand::SaveQueue { name, result_tx }) + .await?; + let result = result_rx.recv_async().await.map_err(|err| { + error!("no reply from playback loop: {err}"); + Status::internal("playback loop did not reply") + })?; + match result { + Ok(()) => Ok(Response::new(SaveQueueResponse {})), + Err(err) => Err(capture_error_status(err, "cannot save the queue")), + } } } diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index d877d86..0cd3910 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -15,10 +15,14 @@ use serde::{Deserialize, Serialize}; /// The server config file name inside the crabidy config directory. pub const SETTINGS_FILE: &str = "crabidy-server.toml"; -/// Every built-in provider, in the order the default config lists them. Each -/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, -/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the -/// store, so it needs `crabidy`. +/// Every provider name the runtime toggle understands, in the order the +/// default config lists them. Each name is a library root (`/tidal`, +/// `/youtube`, `/fyyd`, `/abs`, `/soundcloud`, `/jamendo`, `/fs`, `/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] = [ "tidal", "youtube", @@ -31,6 +35,32 @@ pub const ALL_PROVIDERS: [&str; 9] = [ "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`. #[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -86,19 +116,23 @@ pub struct ProviderToggles { } impl ProviderToggles { - /// Every provider on — the default, and what a missing/keyless config - /// yields. + /// Every provider this binary has on — the default, and what a + /// 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 { Self { - tidal: true, - youtube: true, - fyyd: true, - abs: true, - soundcloud: true, - jamendo: true, - fs: true, - crabidy: true, - orphans: true, + tidal: cfg!(feature = "tidal"), + youtube: cfg!(feature = "youtube"), + fyyd: cfg!(feature = "fyyd"), + abs: cfg!(feature = "abs"), + soundcloud: cfg!(feature = "soundcloud"), + jamendo: cfg!(feature = "jamendo"), + fs: cfg!(feature = "fs"), + // Both live on the content store, which the `fs` feature brings + // (D5). + crabidy: cfg!(feature = "fs"), + orphans: cfg!(feature = "fs"), } } } @@ -186,12 +220,33 @@ impl ServerSettings { Ok(settings) } - /// Whether the named provider is enabled: every provider when the - /// `providers` key is absent, otherwise only the names the list holds. + /// Whether the named provider is enabled: every *built-in* provider when + /// 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 { + BUILT_IN_PROVIDERS.contains(&name) + && self + .providers + .as_ref() + .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_ref() - .is_none_or(|list| list.iter().any(|p| p == name)) + .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 @@ -211,10 +266,11 @@ impl ServerSettings { } } - /// Writes a default `crabidy-server.toml` — all providers enabled, no auth - /// — when none exists yet, so users have a full list to prune. A file that - /// already exists (even a pruned one) is left untouched. Best-effort: the - /// caller treats a write failure as a warning, not a startup error. + /// Writes a default `crabidy-server.toml` — every provider this binary has + /// ([`BUILT_IN_PROVIDERS`]), no auth — when none exists yet, so users have + /// a full list to prune. A file that already exists (even a pruned one) is + /// left untouched. Best-effort: the caller treats a write failure as a + /// warning, not a startup error. pub fn ensure_default(config_dir: &Path) -> Result<(), String> { let file = config_dir.join(SETTINGS_FILE); match std::fs::metadata(&file) { @@ -223,7 +279,7 @@ impl ServerSettings { Err(err) => return Err(format!("cannot check {}: {err}", file.display())), } 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(), audio: AudioSettings::default(), }; @@ -370,17 +426,78 @@ mod tests { } #[test] - fn an_absent_providers_key_enables_everything() { + fn an_absent_providers_key_enables_every_built_in_provider() { let settings = ServerSettings::default(); - for provider in ALL_PROVIDERS { + for provider in BUILT_IN_PROVIDERS { assert!(settings.provider_enabled(provider), "{provider}"); } - let toggles = settings.provider_toggles(); - assert!( - toggles.tidal && toggles.youtube && toggles.fs && toggles.crabidy && toggles.orphans - ); + // …and nothing this binary was built without. + for provider in ALL_PROVIDERS { + 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 { + 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] fn an_explicit_list_enables_only_its_names() { let dir = TempDir::new().expect("tempdir"); @@ -404,7 +521,7 @@ mod tests { let dir = TempDir::new().expect("tempdir"); ServerSettings::ensure_default(dir.path()).expect("write default"); let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); - for provider in ALL_PROVIDERS { + for provider in BUILT_IN_PROVIDERS { assert!(text.contains(provider), "default lists {provider}"); } assert!( @@ -414,7 +531,7 @@ mod tests { let reloaded = ServerSettings::load(dir.path()).expect("reload"); assert_eq!( 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. std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune"); diff --git a/devenv.nix b/devenv.nix index bc2e2d5..93dae8f 100644 --- a/devenv.nix +++ b/devenv.nix @@ -35,7 +35,10 @@ let ]; in { - imports = [ ./devenv-rust.nix ]; + imports = [ + ./devenv-rust.nix + ./devenv-docs.nix + ]; # The wasm target for cbd-web; merges with the languages.rust # settings in devenv-rust.nix. @@ -80,6 +83,51 @@ in scripts.gen-cli-assets.exec = '' 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 = ""; diff --git a/plan/build-features.md b/plan/build-features.md new file mode 100644 index 0000000..68728c6 --- /dev/null +++ b/plan/build-features.md @@ -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 }`, + `Mount::new(root, name, Arc) -> Mount` (the `Arc` + coerces at the call site), and `Mount::owns(&self, path) -> bool` matching + `path == root || path.starts_with("/")` — so `/fsx` is not `/fs`. + *Verifies:* `mount_owns_its_root_and_children_only` (G10). +- [ ] **A2 — `ProviderOrchestrator` holds `mounts: Vec`.** Replace the + nine `*_client` fields (keep `crabidy_store`). Add + `from_mounts(Vec) -> 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>` + 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. diff --git a/quality/build-features.md b/quality/build-features.md new file mode 100644 index 0000000..325eb51 --- /dev/null +++ b/quality/build-features.md @@ -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 `` and `/…` 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`. *(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` 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.