build: put every provider, opus, and the spectrum behind cargo features
All on by default, so a plain build is unchanged (verified: the default dependency set for crabidy-server is byte-identical to before). Tailor a smaller binary with --no-default-features --features … (architecture/build-features.md). Compile-time features draw dependency boundaries; the existing crabidy-server.toml providers list keeps doing per-mount runtime toggling. The compile-time set bounds the runtime one: a provider built out cannot be enabled from the config, and naming it earns one startup warning rather than silence. - crabidy-server: tidal · youtube · fyyd · abs · soundcloud · jamendo · fs · opus · spectrum · web-ui, plus the all-providers group. - fs is local files *and* persistent state (D5): the /fs mount, the content store behind /crabidy and /orphans, bookmarks/captures, queue persistence, and scan. Without it Capture/SaveQueue answer Unimplemented and scan says which feature is missing — never a panic. - opus drops symphonia + symphonia-adapter-libopus, and with them the bundled libopus C build (no more cmake requirement). It also decides whether scan indexes .opus at all, so scan never indexes what this build cannot play. An Ogg-Opus file in an opus-less build reports the missing feature and is skipped like any undecodable file. - spectrum drops realfft and the FFT task; clients just never receive a frame. cbd-tui gains notifications (notify-rust, a D-Bus stack). - crabidy-server/cbd features print the compiled set, and startup logs it, so a tailored binary is self-describing. Not gated, deliberately: [auth]/argon2 (a build ignoring configured hashes would run open — fail-open security hole), and hls.rs / spectrum_tap.rs / windowed_http.rs (no dependency of their own, so gating them buys cfg noise and nothing else). devenv gains check-features: the curated matrix (defaults, nothing, each provider alone, each axis dropped, both worked examples, the client crates) all clippy-clean under -D warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1b838578e2
commit
a03e3de84e
11
Cargo.toml
11
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" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
# Build features (tailored, non-bloated builds)
|
||||
|
||||
Cargo features that let a build drop whole subsystems — each provider,
|
||||
Opus decoding, the spectrum, the embedded web UI, desktop notifications —
|
||||
together with the dependencies those subsystems pull in. Everything is
|
||||
**on by default**: a plain `cargo build` produces today's binary, and
|
||||
`--no-default-features --features …` produces an appliance build.
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
`crabidy-server` links every provider unconditionally: `tidaldy`,
|
||||
`ytdy` (and through it `rustypipe`), `fyyd`, `absdy`, `soundclouddy`,
|
||||
`jamendody`, `fsdy`. `audio-player` always links `symphonia` plus
|
||||
`symphonia-adapter-libopus`, which **bundles libopus and therefore
|
||||
requires `cmake` + `ninja` at build time**. `cbd-tui` always links
|
||||
`notify-rust` (a D-Bus stack on Linux). A user who wants "a Raspberry Pi
|
||||
that plays my local flac collection" compiles, links, and ships all of
|
||||
it.
|
||||
|
||||
There is already a **runtime** switch — `crabidy-server.toml`'s
|
||||
`providers = [...]` list ([`settings::ProviderToggles`]) — but it only
|
||||
decides what gets *mounted*; every dependency is still compiled and
|
||||
linked. The ask is the compile-time half, aligned with the dependency
|
||||
graph so a tailored build is genuinely smaller.
|
||||
|
||||
Two questions from the request are answered here: `scan` **does**
|
||||
already treat `.opus` as playable (`cli.rs: AUDIO_EXTENSIONS`), and that
|
||||
entry now becomes conditional on the `opus` feature (D6); and disabling
|
||||
`fs` also drops `/crabidy` and `/orphans` (D5).
|
||||
|
||||
## Assumptions (decided)
|
||||
|
||||
- **Default-on, opt-out.** No user's build changes unless they ask. The
|
||||
entry point for tailoring is `--no-default-features`.
|
||||
- **Two layers, different jobs.** Compile-time features decide what is
|
||||
*linked*; the existing `providers` list decides what is *mounted*.
|
||||
A provider that is compiled in can still be turned off in the toml;
|
||||
a provider that is not compiled in cannot be turned on.
|
||||
- **The wire protocol is feature-independent.** No `.proto` changes, no
|
||||
feature-conditional RPCs, no client/server feature coupling. A client
|
||||
asking for `/tidal` on a tidal-less server gets exactly what it gets
|
||||
today from a runtime-disabled provider (`MalformedPath` → gRPC
|
||||
`InvalidArgument`), and the root listing simply does not offer it.
|
||||
Clients (`cbd-tui`, `cbd-web`) need **no** knowledge of server
|
||||
features.
|
||||
- **A feature must pay for itself in dependencies.** Adding `#[cfg]`
|
||||
noise to gate code that shares its dependencies with code that stays
|
||||
is a net loss — that is what the runtime toggles are for. See D5 for
|
||||
where this bites (`crabidy`/`orphans`) and D9 for what we refuse to
|
||||
gate.
|
||||
|
||||
## Options considered
|
||||
|
||||
### How to gate the providers inside the orchestrator
|
||||
|
||||
`ProviderOrchestrator` holds nine `Option<Arc<ConcreteClient>>` fields
|
||||
and dispatches with a hand-written `if …_owns(path) { … }` chain,
|
||||
repeated verbatim across eight trait methods (`is_track_path`,
|
||||
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
|
||||
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
|
||||
`resolve_tracks_into`) plus `get_lib_root`.
|
||||
|
||||
1. **Sprinkle `#[cfg(feature = …)]`** on every field, every
|
||||
`*_owns`/`*_provider` helper, and every branch of every chain:
|
||||
~130 attributes, nine of them per method body, in a 1000-line file
|
||||
that then only compiles in one shape per feature combination.
|
||||
Rejected: unmaintainable, and each new provider multiplies it.
|
||||
2. **A mount registry** (chosen). The nine dispatch chains collapse into
|
||||
one lookup, because every branch is already the *same* code modulo
|
||||
the client. `ProviderClient` is dyn-compatible (its only non-`&self`
|
||||
method, `init`, carries `where Self: Sized`), so mounts can be held
|
||||
as `Arc<dyn ProviderClient>`:
|
||||
|
||||
```rust
|
||||
struct Mount {
|
||||
root: &'static str, // "/tidal", "/fs", …
|
||||
name: &'static str, // root-listing title
|
||||
client: Arc<dyn ProviderClient>,
|
||||
}
|
||||
```
|
||||
|
||||
Dispatch becomes "find the mount whose root owns this path, or
|
||||
`MalformedPath`". A provider is then gated in exactly **one** place —
|
||||
its registration in `build()` — plus its `Cargo.toml` line. This
|
||||
deletes ~600 lines of repetition and is a strict prerequisite for the
|
||||
feature work, so it lands first, on its own, with behaviour
|
||||
unchanged.
|
||||
|
||||
Ordering of the root listing (crabidy first, orphans last, rest
|
||||
alphabetical) is a property of the assembled child list and is
|
||||
preserved by the registry (it sorts the same way).
|
||||
|
||||
### Where the feature flags live
|
||||
|
||||
`crabidy-server` is the hub and owns the user-facing names.
|
||||
`audio-player` gets internal features that the server turns on
|
||||
(`opus`, `hls`, `spectrum`); `cbd` (the bundle) forwards the server's
|
||||
set so `-p cbd` is tailorable too; `cbd-tui` owns `notifications`.
|
||||
`crabidy-core` stays feature-free — it is the shared proto/trait crate
|
||||
and every configuration needs all of it.
|
||||
|
||||
## Decisions
|
||||
|
||||
**D1 — Feature set.** `crabidy-server`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["all-providers", "opus", "spectrum", "web-ui"]
|
||||
all-providers = ["tidal", "youtube", "fyyd", "abs", "soundcloud",
|
||||
"jamendo", "fs"]
|
||||
|
||||
tidal = ["dep:tidaldy"]
|
||||
youtube = ["dep:ytdy"]
|
||||
fyyd = ["dep:fyyd"]
|
||||
abs = ["dep:absdy"]
|
||||
soundcloud = ["dep:soundclouddy"]
|
||||
jamendo = ["dep:jamendody"]
|
||||
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest"]
|
||||
opus = ["audio-player/opus"]
|
||||
spectrum = ["dep:realfft"]
|
||||
web-ui = ["dep:tonic-web", "dep:include_dir"]
|
||||
```
|
||||
|
||||
`cbd` mirrors every one of them as a pass-through
|
||||
(`tidal = ["crabidy-server/tidal"]`, …) and adds
|
||||
`notifications = ["cbd-tui/notifications"]`. `cbd-tui`:
|
||||
`default = ["notifications"]`, `notifications = ["dep:notify-rust"]`.
|
||||
`audio-player` gets exactly one feature — `default = ["opus"]`,
|
||||
`opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]` — for the
|
||||
reason in D7.
|
||||
|
||||
**D2 — A build with no providers is legal.** `--no-default-features`
|
||||
must compile and run: the server starts, serves an empty library root,
|
||||
and plays nothing. It is the base case of the matrix (D11) and the
|
||||
cheapest possible smoke test of the gating. It is not a *useful*
|
||||
deployment, and the startup log says so.
|
||||
|
||||
**D3 — Names match the runtime toggles.** The feature names are exactly
|
||||
the strings in `providers = [...]` (`tidal`, `youtube`, `fyyd`, `abs`,
|
||||
`soundcloud`, `jamendo`, `fs`). One vocabulary for both layers.
|
||||
|
||||
**D4 — The compiled-in set is discoverable.** `settings` gains a
|
||||
compile-time `BUILT_IN_PROVIDERS` (the feature-filtered version of
|
||||
today's `ALL_PROVIDERS`). Consequences:
|
||||
|
||||
- the default `crabidy-server.toml` written on first run lists only
|
||||
providers this binary has;
|
||||
- a name in the user's list that this binary lacks logs one clear
|
||||
warning at startup (`providers lists "tidal", but this binary was
|
||||
built without it`) — a warning, not a startup abort, because the
|
||||
library layer is fail-open by design (unlike `[auth]`);
|
||||
- an unknown name (a typo) warns the same way;
|
||||
- `crabidy-server features` / `cbd features` prints the compiled set
|
||||
(providers + `opus`/`spectrum`/`web-ui`/`notifications`), and the
|
||||
same list goes into one `info!` line at startup. "Why is `/tidal`
|
||||
missing?" is then answerable from the binary and the log.
|
||||
|
||||
**D5 — `fs` owns local files *and* persistent state; `crabidy` and
|
||||
`orphans` stay runtime-only.** As requested, disabling `fs` disables
|
||||
`/crabidy` and `/orphans` too — but they are not separate *features*,
|
||||
because they add no dependency of their own: `crabidy_store.rs`,
|
||||
`capture.rs`, and `orphans.rs` are all written in terms of `fsdy` types
|
||||
(`fsdy::TrackFile`, `Playable`, `AlbumMeta`, `dir_name`). Gating them
|
||||
separately would buy nothing and cost three more `#[cfg]` dimensions.
|
||||
So the `fs` feature is one coherent unit — "local files and persistent
|
||||
state" — covering:
|
||||
|
||||
| gated by `fs` | consequence when off |
|
||||
| --- | --- |
|
||||
| `fsdy` client, `/fs` mount | no `/fs` |
|
||||
| `crabidy_store` + `/crabidy` mount | no saved queues, bookmarks, captures |
|
||||
| `orphans` + `/orphans` mount | no GC view |
|
||||
| queue persistence | in-memory only; a restart starts empty |
|
||||
| `capture` (bookmarks, downloads) | capture/save RPCs `Unimplemented` |
|
||||
| `annotate_captured` | no captured markers (clients handle absent flags) |
|
||||
| the `scan` CLI command | fails with "built without the `fs` feature" |
|
||||
|
||||
Users who want `/fs` but not `/crabidy` keep doing what they do today:
|
||||
prune the `providers` list.
|
||||
|
||||
**D6 — `opus` is the biggest single win and is not tied to a provider.**
|
||||
It drops `symphonia`, `symphonia-adapter-libopus`, and with them the
|
||||
bundled libopus C build (`cmake` + `ninja` disappear from the build
|
||||
requirements — the reason this feature is worth its `#[cfg]`s).
|
||||
Ogg-Opus files reach the player from `/abs`, `/fs`, and `/crabidy`
|
||||
alike, so it stays an independent axis. With `opus` off:
|
||||
|
||||
- `player_engine::build_source` skips the sniff and hands everything to
|
||||
rodio's decoder — an Ogg-Opus file then fails to decode with a clear
|
||||
error (`this build has no Opus decoder`) and playback skips the track,
|
||||
exactly as any undecodable file does today. **No panic** (hard rule).
|
||||
- `cli.rs: AUDIO_EXTENSIONS` drops `"opus"`, so `scan` no longer indexes
|
||||
`.opus` files it could not play. (It *does* index them today; that is
|
||||
correct behaviour for a build that has the decoder.)
|
||||
|
||||
**D7 — HLS playback is *not* gated.** SoundCloud is the only provider
|
||||
that returns an `.m3u8` (`soundclouddy` resolves HLS media URLs), so
|
||||
`audio-player/src/hls.rs` is dead code in a build without `soundcloud`
|
||||
— but it imports only crates the player needs anyway (`bytes`,
|
||||
`futures`, `stream-download`, `url`, `reqwest`). Gating it would buy a
|
||||
few KB of code and cost a `#[cfg]` dimension across the decode path, so
|
||||
it stays unconditional. Same reasoning for the windowed-HTTP source. The
|
||||
rule this follows is the one in the assumptions: **a feature must pay
|
||||
for itself in dependencies.**
|
||||
|
||||
**D8 — `spectrum` gates the server side only.** `realfft`, `spectrum.rs`
|
||||
and `spawn_spectrum_task` go away; the `SpectrumFrame` proto message,
|
||||
the player's sample tap, and both clients' rendering stay. A client
|
||||
subscribed to the update stream simply never receives a frame, which it
|
||||
already handles (the bars stay dark). The tap itself
|
||||
(`audio-player/src/spectrum_tap.rs`) is not gated — like `hls.rs` it
|
||||
brings no dependency (std + rodio), and it is woven through
|
||||
`player_engine`'s decode path via `TappingSource`, so `Player`'s public
|
||||
surface stays feature-invariant.
|
||||
|
||||
**D9 — Deliberately *not* behind features.**
|
||||
|
||||
- **`[auth]` / `argon2`.** A binary built without auth would ignore
|
||||
configured role hashes and run open — a fail-open security hole for a
|
||||
~200 KB dependency. Refused. (If it is ever added, it must *abort*
|
||||
startup when `[auth]` is non-empty.)
|
||||
- **Audio output / `rodio` / ALSA.** The server *is* the player; a
|
||||
server with no audio output has no purpose here.
|
||||
- **`web-ui` on the clients.** `cbd-web` is its own crate; not building
|
||||
it is already the way to not have it.
|
||||
- **TUI spectrum rendering, TUI/web feature parity.** No dependency
|
||||
behind them (D8).
|
||||
- **The CLI surface.** The clap definitions live in `cbd-cli`, which
|
||||
depends on none of the gated crates. Keeping the surface constant
|
||||
means completions and the man page do not vary per build; a command
|
||||
whose backing feature is absent fails with a clear message (D5).
|
||||
|
||||
**D10 — `flake.nix` must be updated in the same change.** Its native
|
||||
build passes a bare `--no-default-features` (today: "everything except
|
||||
`web-ui`"). After D1 that would silently produce a **provider-less**
|
||||
binary. It becomes an explicit list — `--no-default-features --features
|
||||
all-providers,opus,spectrum` — and the aarch64 cross build (full
|
||||
defaults, `web-ui` on) stays as is. `devenv.nix` gains scripts for the
|
||||
tailored builds so the matrix is one command.
|
||||
|
||||
**D11 — Verification is a build matrix, not a powerset.** Feature
|
||||
combinatorics are the real risk: unused imports/dead code under odd
|
||||
combinations, and `-D warnings` in the pre-commit hook. A curated matrix
|
||||
(D2's empty build, defaults, each provider alone, `fs`-only,
|
||||
`opus`-off, `spectrum`-off, `web-ui`-off, and the two client crates)
|
||||
gives the coverage that matters; `cargo hack --each-feature` is
|
||||
available in devenv for a deeper sweep when the flags change.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
features: crabidy-server features {
|
||||
providers: "tidal · youtube · fyyd\nabs · soundcloud · jamendo · fs"
|
||||
opus: opus
|
||||
spectrum: spectrum
|
||||
webui: web-ui
|
||||
}
|
||||
|
||||
server: crabidy-server {
|
||||
registry: "mount registry\nArc<dyn ProviderClient>"
|
||||
store: "crabidy_store + capture\n+ orphans"
|
||||
fft: "spectrum.rs (realfft)"
|
||||
web: "web.rs (tonic-web,\ninclude_dir)"
|
||||
}
|
||||
|
||||
player: audio-player {
|
||||
ap_opus: "opus_source\n(symphonia + libopus,\nneeds cmake)"
|
||||
ap_rest: "hls · windowed_http\n· spectrum_tap\n(never gated: no own deps)"
|
||||
}
|
||||
|
||||
deps: provider crates {
|
||||
tidaldy
|
||||
ytdy: "ytdy → rustypipe"
|
||||
fyyd
|
||||
absdy
|
||||
soundclouddy
|
||||
jamendody
|
||||
fsdy: "fsdy + blake3"
|
||||
}
|
||||
|
||||
features.providers -> server.registry: mounts
|
||||
features.providers -> deps: "dep:*"
|
||||
features.providers -> server.store: "fs only"
|
||||
features.opus -> player.ap_opus
|
||||
features.spectrum -> server.fft
|
||||
features.webui -> server.web
|
||||
```
|
||||
|
||||
Dispatch after the registry refactor — one path, whatever is compiled
|
||||
in:
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
rpc: RPC / provider loop
|
||||
root: "path == /"
|
||||
lookup: "mount whose root owns the path"
|
||||
client: "Arc<dyn ProviderClient>"
|
||||
none: "MalformedPath / NotSupported"
|
||||
listing: "root listing\n(crabidy, …, orphans)"
|
||||
|
||||
rpc -> root
|
||||
root -> listing: yes
|
||||
root -> lookup: no
|
||||
lookup -> client: found
|
||||
lookup -> none: no owner
|
||||
```
|
||||
|
||||
## Boundaries and interfaces
|
||||
|
||||
- **`ProviderOrchestrator`** — the only place a provider is named. New
|
||||
shape: `mounts: Vec<Mount>` plus the `Option<Arc<CrabidyStore>>` that
|
||||
`fs` brings (the store is not a mount; it is a writer other
|
||||
subsystems share). Public surface (`build`, `run`, `provider_tx`,
|
||||
`crabidy_store`, the `ProviderClient` impl) is unchanged.
|
||||
- **`settings`** — `BUILT_IN_PROVIDERS` (compile-time) and
|
||||
`ProviderToggles` (runtime) meet here; `provider_toggles()` returns
|
||||
toggles only for providers this binary has.
|
||||
- **`audio-player`** — public API is feature-invariant (`Player`,
|
||||
`PlayerMessage`, `SpectrumTap`, `output_device_names`); features
|
||||
change only what is inside.
|
||||
- **Proto / clients** — untouched.
|
||||
|
||||
## Risks
|
||||
|
||||
- **`#[cfg]` rot.** A combination nobody builds breaks silently.
|
||||
Mitigated by D11's matrix in devenv scripts (and CI when there is
|
||||
one).
|
||||
- **Startup surprise.** A user upgrading a distro package built without
|
||||
`youtube` sees `/youtube` vanish with no clue. Mitigated by D4
|
||||
(startup log line, `features` command, warning when the toml names a
|
||||
provider the binary lacks).
|
||||
- **`flake.nix` silently shipping an empty build.** D10; it is the first
|
||||
thing the plan changes after the manifests.
|
||||
- **The registry refactor touching every dispatch path.** Landed as its
|
||||
own commit with no feature changes, so a regression bisects cleanly.
|
||||
`resolve_tracks_into`'s per-provider overrides keep working — it is a
|
||||
trait method, dispatched dynamically like the rest.
|
||||
- **Opus files in an opus-less build.** They fail to decode and are
|
||||
skipped, with a message naming the missing feature (D6) — never a
|
||||
panic.
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking. Deferred by choice: gating `[auth]` (D9, refused),
|
||||
per-provider *runtime* dynamic loading (out of scope — features are
|
||||
compile-time), and a `minimal` convenience feature (users compose
|
||||
`--no-default-features --features fs,opus` instead).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<Track>) {
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Result<(), Box<dyn Error>> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `scan` without the `fs` feature: the command exists (the clap surface is
|
||||
/// feature-independent, so completions and the man page never vary) but this
|
||||
/// build has no sidecar writer or content store
|
||||
/// (architecture/build-features.md D5/D9).
|
||||
#[cfg(not(feature = "fs"))]
|
||||
pub async fn scan(_args: ScanArgs) -> Result<(), Box<dyn Error>> {
|
||||
Err("this binary was built without the `fs` feature, so it cannot index a music folder".into())
|
||||
}
|
||||
|
||||
/// The build features this binary was compiled with, in a stable order:
|
||||
/// the providers it can mount ([`crate::settings::BUILT_IN_PROVIDERS`])
|
||||
/// followed by the non-provider features (`opus`, `spectrum`, `web-ui`).
|
||||
///
|
||||
/// Used by the `features` subcommand and by the startup log line, so "why is
|
||||
/// `/tidal` missing?" is answerable from the binary itself
|
||||
/// (architecture/build-features.md D4).
|
||||
pub fn build_features() -> Vec<&'static str> {
|
||||
let mut features: Vec<&'static str> = crate::settings::BUILT_IN_PROVIDERS.to_vec();
|
||||
if cfg!(feature = "opus") {
|
||||
features.push("opus");
|
||||
}
|
||||
if cfg!(feature = "spectrum") {
|
||||
features.push("spectrum");
|
||||
}
|
||||
if cfg!(feature = "web-ui") {
|
||||
features.push("web-ui");
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
/// `features`: print what this build can do — one feature per line, so it is
|
||||
/// greppable — and where the runtime `providers` list that prunes it further
|
||||
/// lives (architecture/build-features.md D4).
|
||||
pub fn features() -> Result<(), Box<dyn Error>> {
|
||||
for feature in build_features() {
|
||||
println!("{feature}");
|
||||
}
|
||||
let path = config_dir()
|
||||
.map(|dir| {
|
||||
dir.join(crate::settings::SETTINGS_FILE)
|
||||
.display()
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| crate::settings::SETTINGS_FILE.to_string());
|
||||
eprintln!(
|
||||
"\nProviders can be pruned further at runtime with the `providers` list in {path}; \
|
||||
names missing above are not in this binary and cannot be enabled there."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The outcome of a `scan` walk, for a concise summary and for tests.
|
||||
#[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 <path> [--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<dyn Error>> {
|
||||
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<dyn Error>> {
|
|||
/// 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");
|
||||
|
|
|
|||
|
|
@ -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<audio_player::SpectrumTap>,
|
||||
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<Result<(), crate::capture::CaptureError>>,
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StreamUpdate>,
|
||||
|
|
@ -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<Arc<CrabidyStore>>,
|
||||
/// 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<Option<QueueSnapshot>>,
|
||||
pub player: Player,
|
||||
}
|
||||
|
|
@ -39,12 +50,13 @@ impl Playback {
|
|||
pub fn new(
|
||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||
provider_tx: flume::Sender<ProviderMessage>,
|
||||
store: Option<Arc<CrabidyStore>>,
|
||||
#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>,
|
||||
audio_device: Option<String>,
|
||||
) -> 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<CrabidyStore> {
|
||||
Arc::new(
|
||||
CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
|
||||
|
|
@ -867,18 +892,26 @@ mod tests {
|
|||
)
|
||||
}
|
||||
|
||||
fn playback_with(store: Option<Arc<CrabidyStore>>) -> Playback {
|
||||
fn playback_with(#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>) -> Playback {
|
||||
let (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
let (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<Track> = (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<Track> = (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");
|
||||
|
|
|
|||
|
|
@ -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<dyn ProviderClient>) -> 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<C>(
|
||||
config_dir: &std::path::Path,
|
||||
config_file: &str,
|
||||
|
|
@ -102,7 +120,9 @@ pub struct ProviderOrchestrator {
|
|||
mounts: Vec<Mount>,
|
||||
/// 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<Arc<CrabidyStore>>,
|
||||
}
|
||||
|
||||
|
|
@ -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<Arc<CrabidyStore>> {
|
||||
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<Mount> = 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,16 +343,26 @@ 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.
|
||||
// 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 {
|
||||
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))
|
||||
|
|
@ -411,6 +446,8 @@ impl ProviderOrchestrator {
|
|||
));
|
||||
}
|
||||
}
|
||||
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::<ytdy::Client>(
|
||||
|
|
@ -435,6 +473,7 @@ impl ProviderOrchestrator {
|
|||
.await,
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "fyyd")]
|
||||
if enabled.fyyd {
|
||||
mounts.extend(
|
||||
mount_from_config::<fyyd::Client>(
|
||||
|
|
@ -446,6 +485,7 @@ impl ProviderOrchestrator {
|
|||
.await,
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "abs")]
|
||||
if enabled.abs {
|
||||
mounts.extend(
|
||||
mount_from_config::<absdy::Client>(
|
||||
|
|
@ -457,6 +497,7 @@ impl ProviderOrchestrator {
|
|||
.await,
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "soundcloud")]
|
||||
if enabled.soundcloud {
|
||||
mounts.extend(
|
||||
mount_from_config::<soundclouddy::Client>(
|
||||
|
|
@ -468,6 +509,7 @@ impl ProviderOrchestrator {
|
|||
.await,
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "jamendo")]
|
||||
if enabled.jamendo {
|
||||
mounts.extend(
|
||||
mount_from_config::<jamendody::Client>(
|
||||
|
|
@ -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`.
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +434,14 @@ impl CrabidyService for RpcService {
|
|||
&self,
|
||||
request: Request<CaptureLibraryNodeRequest>,
|
||||
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
||||
#[cfg(not(feature = "fs"))]
|
||||
{
|
||||
let _ = request;
|
||||
debug!("received capture_library_node request on a store-less build");
|
||||
return Err(Status::unimplemented(NO_STORE));
|
||||
}
|
||||
#[cfg(feature = "fs")]
|
||||
{
|
||||
let CaptureLibraryNodeRequest {
|
||||
path,
|
||||
name,
|
||||
|
|
@ -468,6 +485,7 @@ impl CrabidyService for RpcService {
|
|||
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
|
||||
/// gesture (architecture/crabidy-store.md D5). A bookmark of the live
|
||||
|
|
@ -483,6 +501,14 @@ impl CrabidyService for RpcService {
|
|||
&self,
|
||||
request: Request<SaveQueueRequest>,
|
||||
) -> Result<Response<SaveQueueResponse>, Status> {
|
||||
#[cfg(not(feature = "fs"))]
|
||||
{
|
||||
let _ = request;
|
||||
debug!("received save_queue request on a store-less build");
|
||||
Err(Status::unimplemented(NO_STORE))
|
||||
}
|
||||
#[cfg(feature = "fs")]
|
||||
{
|
||||
let name = request.into_inner().name;
|
||||
tracing::Span::current().record("name", name.as_str());
|
||||
debug!("received save_queue request");
|
||||
|
|
@ -498,6 +524,7 @@ impl CrabidyService for RpcService {
|
|||
Err(err) => Err(capture_error_status(err, "cannot save the queue")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, _request))]
|
||||
async fn toggle_play(
|
||||
|
|
|
|||
|
|
@ -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,14 +220,35 @@ 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 {
|
||||
self.providers
|
||||
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_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
|
||||
/// requires `crabidy` (it is a view over the store); that dependency is
|
||||
/// enforced where the store is built, so it is not folded in here.
|
||||
|
|
@ -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<String> {
|
||||
ServerSettings::load(dir)
|
||||
.expect("reload")
|
||||
.unavailable_providers()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Asserts on `fs`/`crabidy`, so it needs a build that has them.
|
||||
#[cfg(feature = "fs")]
|
||||
#[test]
|
||||
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");
|
||||
|
|
|
|||
50
devenv.nix
50
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 = "";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
# Plan — build features
|
||||
|
||||
Executes `architecture/build-features.md` against `quality/build-features.md`.
|
||||
Ordered by dependency; each task names how it is verified. Three commits:
|
||||
**(A)** the mount-registry refactor (no feature change), **(B)** the features
|
||||
themselves, **(C)** packaging + docs.
|
||||
|
||||
Build commands run through devenv with the session-local target dir:
|
||||
|
||||
```sh
|
||||
devenv shell -- bash -lc 'CARGO_TARGET_DIR="$(pwd)/target-claude" cargo … '
|
||||
```
|
||||
|
||||
## A — Mount registry (behaviour-preserving refactor)
|
||||
|
||||
- [ ] **A1 — `Mount` + registry types.** In `provider.rs`: `struct Mount {
|
||||
root: &'static str, name: &'static str, client: Arc<dyn ProviderClient> }`,
|
||||
`Mount::new(root, name, Arc<impl ProviderClient>) -> Mount` (the `Arc`
|
||||
coerces at the call site), and `Mount::owns(&self, path) -> bool` matching
|
||||
`path == root || path.starts_with("<root>/")` — so `/fsx` is not `/fs`.
|
||||
*Verifies:* `mount_owns_its_root_and_children_only` (G10).
|
||||
- [ ] **A2 — `ProviderOrchestrator` holds `mounts: Vec<Mount>`.** Replace the
|
||||
nine `*_client` fields (keep `crabidy_store`). Add
|
||||
`from_mounts(Vec<Mount>) -> Self` (sorts the mounts crabidy-first /
|
||||
orphans-last / rest alphabetical, and creates the bounded channel), used by
|
||||
`build` and the tests. *Verifies:* `root_lists_mounted_providers_in_order`
|
||||
(G12).
|
||||
- [ ] **A3 — One dispatch helper.** `owner(&self, path) -> Option<&dyn
|
||||
ProviderClient>` plus `owner_or(&self, path, err)` that warns with the same
|
||||
message and returns the typed error. Delete the nine `*_owns` functions and
|
||||
the nine `*_provider` accessors. *Verifies:* `unowned_paths_are_typed_errors`
|
||||
(G11).
|
||||
- [ ] **A4 — Rewrite the eight dispatch methods** (`is_track_path`,
|
||||
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
|
||||
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
|
||||
`resolve_tracks_into`) as a single lookup each, preserving: the synthetic
|
||||
root short-circuit and `annotate_captured` in `get_lib_node`,
|
||||
`MalformedPath` for lookups, `NotSupported` for mutations, and the
|
||||
`warn!`s. *Verifies:* `dispatch_reaches_the_owning_mount`,
|
||||
`overridden_resolve_tracks_into_is_dispatched`,
|
||||
`no_mounts_serves_an_empty_root` (G10, G11, G13).
|
||||
- [ ] **A5 — `get_lib_root` maps the sorted mounts** to `LibraryNodeChild::new`
|
||||
(no per-provider block, no re-sort). *Verifies:*
|
||||
`root_lists_mounted_providers_in_order` (G12).
|
||||
- [ ] **A6 — `build()` registers mounts.** Each provider's init block stays as
|
||||
is; on success it pushes a `Mount` instead of assigning a field. `/crabidy`
|
||||
and `/orphans` push after the store exists. *Verifies:* full test suite +
|
||||
`cargo clippy -- -D warnings` (G2).
|
||||
- [ ] **A7 — Commit A.** `cargo test -p crabidy-server`, clippy, fmt all clean;
|
||||
no `Cargo.toml` change in this commit (G1/G2 hold trivially).
|
||||
|
||||
## B — The features
|
||||
|
||||
- [ ] **B1 — Manifests** (done in api-design; re-verify): `crabidy-server`
|
||||
(`default = all-providers + opus + spectrum + web-ui`, gated deps optional),
|
||||
`audio-player` (`opus`), `cbd-tui` (`notifications`), `cbd` (pass-through of
|
||||
all of them). *Verifies:* G4, G5; `cargo tree -p crabidy-server` at defaults
|
||||
matches the pre-change tree (G1).
|
||||
- [ ] **B2 — `settings`: `BUILT_IN_PROVIDERS`, `provider_enabled` bounded by
|
||||
it, `unavailable_providers()`, `ensure_default` writing only built-ins.**
|
||||
Replace the `todo!()`. *Verifies:*
|
||||
`an_absent_providers_key_enables_every_built_in_provider`,
|
||||
`unavailable_providers_reports_built_out_and_unknown_names`,
|
||||
`ensure_default_lists_only_built_in_providers`,
|
||||
`a_built_out_provider_cannot_be_enabled` (G4, G25, G26).
|
||||
- [ ] **B3 — Per-provider `#[cfg]` in `build()` only.** Wrap each provider's
|
||||
init+registration block in `#[cfg(feature = "…")]`; the `enabled.*` toggle
|
||||
read stays inside the block. Provider `use`/imports move to the same gate.
|
||||
*Verifies:* the matrix (B12) compiles each provider alone; G9 by reading.
|
||||
- [ ] **B4 — `fs`: gate the store half.** `#[cfg(feature = "fs")]` on the
|
||||
`crabidy_store`, `capture`, and `orphans` modules (`lib.rs`), on
|
||||
`ProviderOrchestrator::crabidy_store`/its field, on `Playback`'s store field
|
||||
and `restore_current` and the persister, and on the `/fs`, `/crabidy`,
|
||||
`/orphans` registrations. `Playback::new` keeps its arity — the store
|
||||
parameter becomes `#[cfg]`-free by threading `Option<Arc<CrabidyStore>>`
|
||||
only under the feature (adjust the two call sites in `lib.rs` and the
|
||||
playback tests). *Verifies:* G14, G17; `--no-default-features` build (B12).
|
||||
- [ ] **B5 — `fs`: RPC degradation.** Without `fs`, `CaptureLibraryNode` and
|
||||
`SaveQueue` return `Status::unimplemented` naming the feature; with `fs`,
|
||||
unchanged. No panic, no hang. *Verifies:* G15 by reading + a
|
||||
`--no-default-features` smoke run.
|
||||
- [ ] **B6 — `fs`: the `scan` command.** Gate `AUDIO_EXTENSIONS`,
|
||||
`is_audio_file`, `scan`, `scan_dir`, `scan_file` and their tests behind
|
||||
`fs`; without it the dispatcher in `crabidy-server/src/main.rs` and
|
||||
`cbd/src/main.rs` prints "this binary was built without the `fs` feature"
|
||||
and exits non-zero. The clap surface does not change. *Verifies:* G16, G9.
|
||||
- [ ] **B7 — `opus` in `audio-player`.** Gate `mod opus_source` and the
|
||||
`is_ogg_opus` sniff in `player_engine::build_source`; without the feature an
|
||||
Ogg-Opus header produces `Err` naming the missing feature (the existing
|
||||
"failed to decode" path), never a panic. *Verifies:* G18, G19 (read + the
|
||||
`opus`-off matrix entry); `opus_source`'s own tests stay under the feature.
|
||||
- [ ] **B8 — `opus` in `scan`.** `AUDIO_EXTENSIONS` includes `"opus"` only
|
||||
under the feature. *Verifies:* `opus_is_scannable_only_with_the_decoder`,
|
||||
`scan_walks_past_opus_files_it_cannot_play` (G20).
|
||||
- [ ] **B9 — `spectrum`.** Gate `mod spectrum`, `spawn_spectrum_task`, and its
|
||||
call; `realfft` optional. `Player::spectrum_tap()` unchanged. *Verifies:*
|
||||
G21; the `spectrum`-off matrix entry.
|
||||
- [ ] **B10 — `notifications` in `cbd-tui`.** Gate the `notify_rust` import and
|
||||
the notify call in `app/now_playing.rs`; the config key stays accepted so an
|
||||
existing `cbd-tui.toml` still parses. *Verifies:* G23; `cbd-tui
|
||||
--no-default-features` build + `cargo test -p cbd-tui`.
|
||||
- [ ] **B11 — `features` command.** `cli::build_features()` +
|
||||
`cli::features()` (replace the `todo!()`s), wired into
|
||||
`ServerCommand::Features` and `CbdCommand::Features`; plus one `info!` line
|
||||
at startup listing the
|
||||
build, and one `warn!` per `unavailable_providers()` entry. *Verifies:*
|
||||
`build_features_lists_providers_then_extras` (G24, G25).
|
||||
- [ ] **B12 — The build matrix as a devenv script.** `check-features` runs
|
||||
`cargo clippy -- -D warnings` (and `cargo test` where meaningful) over:
|
||||
defaults · `--no-default-features` · each of the seven providers alone ·
|
||||
defaults-minus-`opus` · minus-`spectrum` · minus-`web-ui` · `-p cbd-tui
|
||||
--no-default-features` · `-p audio-player --no-default-features`. Add
|
||||
`cargo-hack` to devenv for ad-hoc deeper sweeps. *Verifies:* G6, G7.
|
||||
- [ ] **B13 — Commit B** once the whole matrix is green and no
|
||||
`todo!()`/`unimplemented!()` from the stub stage remains (G28).
|
||||
|
||||
## C — Packaging and docs
|
||||
|
||||
- [ ] **C1 — `flake.nix`.** Native build: replace the bare
|
||||
`--no-default-features` with an explicit
|
||||
`--no-default-features --features all-providers,opus,spectrum`; leave the
|
||||
aarch64 cross build on defaults (`web-ui` on). *Verifies:* G8;
|
||||
`nix build .#crabidy` if the sandbox allows, otherwise read + the equivalent
|
||||
cargo invocation.
|
||||
- [ ] **C2 — `docs/src/build-features.md`** — the feature table, what each one
|
||||
costs to lose, the two worked examples (local-files appliance:
|
||||
`--no-default-features --features fs,opus`; streaming box:
|
||||
`--no-default-features --features tidal,web-ui,opus,spectrum`), the
|
||||
compile-time vs runtime distinction, and the `features` command. Linked from
|
||||
`SUMMARY.md`; `docs/src/config.md` cross-references it from the `providers`
|
||||
key. *Verifies:* G27 + `mdbook build`.
|
||||
- [ ] **C3 — `README.md`** gains a short "tailored builds" pointer.
|
||||
*Verifies:* G27.
|
||||
- [ ] **C4 — `plan/summary.md`** entry describing what shipped, the decisions
|
||||
taken autonomously, and what stayed out (auth, HLS, spectrum tap — with the
|
||||
reason). *Verifies:* dev-flow convention.
|
||||
- [ ] **C5 — Commit C.**
|
||||
|
||||
## Deferred / not done (recorded, not silently dropped)
|
||||
|
||||
- Gating `[auth]`/`argon2` — refused (fail-open security risk, D9).
|
||||
- Gating `hls.rs`, `windowed_http.rs`, `spectrum_tap.rs` — no dependency
|
||||
payoff (D7, D8).
|
||||
- A `minimal` convenience feature — users compose their own set (D1).
|
||||
- CI enforcement of the matrix — there is no CI yet; `check-features` is the
|
||||
manual gate.
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
# Quality gates — build features
|
||||
|
||||
Criteria an implementation of `architecture/build-features.md` must satisfy.
|
||||
Each is pass/fail by reading/reasoning or by running one command; automated
|
||||
coverage lives in `crabidy-server/src/settings.rs` (built-in vs configured
|
||||
providers), `crabidy-server/src/cli.rs` (`features`, `scan` extensions), and
|
||||
`crabidy-server/src/provider.rs` (the mount registry: dispatch, ordering,
|
||||
absent providers).
|
||||
|
||||
## The default build is unchanged (highest priority)
|
||||
|
||||
- [ ] **G1 — `cargo build` (no flags) links exactly what it links today.**
|
||||
Every feature is in `default`, so the default dependency graph is identical:
|
||||
`cargo tree -p crabidy-server` before and after differ in nothing but the
|
||||
`optional`/feature annotations. *(gate: diff the tree.)*
|
||||
- [ ] **G2 — No behaviour change at default features.** The full build mounts
|
||||
the same providers in the same order (crabidy first, orphans last, rest
|
||||
alphabetical), persists the queue, captures, serves the web UI, decodes Opus,
|
||||
and streams spectrum frames. *(tests: the existing suites all pass
|
||||
unchanged.)*
|
||||
- [ ] **G3 — The proto is untouched.** No `.proto` change, no
|
||||
feature-conditional RPC, no client-side feature knowledge. `cbd-tui` and
|
||||
`cbd-web` build and run against any server build. *(gate: `git diff` shows
|
||||
no `crabidy-core/crabidy/v1/*.proto` change.)*
|
||||
|
||||
## Feature hygiene
|
||||
|
||||
- [ ] **G4 — Every gated dependency is `optional = true` and reachable only
|
||||
through its feature.** No `dep:` alias is enabled by a path other than its
|
||||
own feature; no gated crate is named in a non-optional dependency line.
|
||||
Applies to `tidaldy`, `ytdy`, `fyyd`, `absdy`, `soundclouddy`, `jamendody`,
|
||||
`fsdy`, `blake3`, `reqwest`, `realfft`, `tonic-web`, `include_dir`
|
||||
(crabidy-server), `symphonia` + `symphonia-adapter-libopus`
|
||||
(audio-player), `notify-rust` (cbd-tui).
|
||||
- [ ] **G5 — Feature names match the runtime vocabulary.** The provider
|
||||
features are spelled exactly as the `providers` entries: `tidal`, `youtube`,
|
||||
`fyyd`, `abs`, `soundcloud`, `jamendo`, `fs` (D3).
|
||||
- [ ] **G6 — `--no-default-features` compiles, links, and runs** for
|
||||
`crabidy-server`, `cbd`, `cbd-tui`, and `audio-player`. The provider-less
|
||||
server starts, answers `Init`, serves an empty library root, and shuts down
|
||||
cleanly (D2).
|
||||
- [ ] **G7 — Every curated matrix entry builds warning-free** under
|
||||
`cargo clippy -- -D warnings`: defaults; `--no-default-features`; each
|
||||
provider alone; `fs` alone; defaults minus `opus`; minus `spectrum`; minus
|
||||
`web-ui`; `cbd-tui --no-default-features`. Exposed as one devenv script
|
||||
(D11). No `#[allow(dead_code)]`/`#[allow(unused)]` added to silence a
|
||||
combination — unused code under a feature combination is a signal to move
|
||||
the `#[cfg]`, not to allow it.
|
||||
- [ ] **G8 — `flake.nix` names its features explicitly.** The native package
|
||||
no longer passes a bare `--no-default-features` (which after D1 would mean
|
||||
"no providers at all"); it lists what it wants. The aarch64 cross build still
|
||||
ships `web-ui` (D10). *(gate: read `flake.nix`; `nix build .#crabidy` yields
|
||||
a binary whose `features` output lists the providers.)*
|
||||
|
||||
## The mount registry
|
||||
|
||||
- [ ] **G9 — A provider is named in exactly two places.** Its `Cargo.toml`
|
||||
feature line and its registration in `ProviderOrchestrator::build`. No
|
||||
`#[cfg(feature = …)]` for a provider anywhere else in `crabidy-server`
|
||||
(the `fs` feature is the documented exception — it also gates the store,
|
||||
capture, orphans, persistence, and `scan`, per D5).
|
||||
- [ ] **G10 — Dispatch is preserved exactly.** For every mounted provider,
|
||||
`is_track_path`, `get_urls_for_track`, `get_metadata_for_track`,
|
||||
`get_lib_node`, `create_lib_node`, `rename_lib_node`, `delete_lib_node`, and
|
||||
`resolve_tracks_into` reach the same client for the same paths as the old
|
||||
chain: a mount owns `<root>` and `<root>/…` and nothing else (`/fsx` is not
|
||||
`/fs`). *(tests: `mount_owns_its_root_and_children_only`,
|
||||
`dispatch_reaches_the_owning_mount`.)*
|
||||
- [ ] **G11 — An unowned path degrades, never panics.** No mount → the same
|
||||
errors as today: `MalformedPath` for lookups/track paths, `NotSupported` for
|
||||
create/rename/delete, `false` for `is_track_path`, with the same
|
||||
`warn!`. *(tests: `unowned_paths_are_typed_errors`.)*
|
||||
- [ ] **G12 — The root listing offers only mounted providers, ordered
|
||||
crabidy → alphabetical → orphans.** A provider built out is simply absent —
|
||||
clients need no signal. *(test:
|
||||
`root_lists_mounted_providers_in_order`.)*
|
||||
- [ ] **G13 — `resolve_tracks_into` still dispatches dynamically.** Providers
|
||||
that override the trait default (paged resolves) keep their override through
|
||||
`Arc<dyn ProviderClient>`. *(test: a fake provider whose override is
|
||||
observed.)*
|
||||
|
||||
## `fs` (local files and persistent state)
|
||||
|
||||
- [ ] **G14 — Without `fs`, nothing references `fsdy`, the store, captures, or
|
||||
orphans.** `crabidy_store`, `capture`, and `orphans` are not compiled;
|
||||
`Playback` holds no store; `restore_current` and the persister are absent or
|
||||
inert.
|
||||
- [ ] **G15 — Store-dependent RPCs degrade cleanly.** `CaptureLibraryNode` and
|
||||
`SaveQueue` answer `Unimplemented` (never a panic, never a hang) with a
|
||||
message naming the missing `fs` feature. Existing clients treat it as any
|
||||
other RPC error.
|
||||
- [ ] **G16 — `scan` without `fs` fails with a clear message** naming the
|
||||
feature, exit code non-zero, no partial writes. The clap surface (and so
|
||||
completions and the man page) is unchanged (D9).
|
||||
- [ ] **G17 — A queue still works without persistence.** Queue, play, next,
|
||||
shuffle, repeat behave normally; only survival across restarts is lost, and
|
||||
the startup log says so once.
|
||||
|
||||
## `opus`
|
||||
|
||||
- [ ] **G18 — With `opus` off, `symphonia`, `symphonia-adapter-libopus`, and
|
||||
the bundled libopus C build are gone.** `cargo tree -p audio-player
|
||||
--no-default-features` shows neither crate; the build needs no `cmake`/
|
||||
`ninja`. *(gate: build in an environment without cmake.)*
|
||||
- [ ] **G19 — An Opus file in an `opus`-less build fails as an undecodable
|
||||
file, not a panic.** `build_source` returns an `Err` whose message names the
|
||||
missing feature; playback logs it and moves on, exactly as for a corrupt
|
||||
file (hard rule: no panic on input).
|
||||
- [ ] **G20 — `scan` indexes `.opus` if and only if the feature is on.**
|
||||
`AUDIO_EXTENSIONS` contains `"opus"` under `opus` and not otherwise; the
|
||||
other extensions are unaffected. *(tests: `scan_indexes_opus_with_the_feature`
|
||||
/ `scan_ignores_opus_without_the_feature`.)*
|
||||
|
||||
## `spectrum`, `web-ui`, `notifications`
|
||||
|
||||
- [ ] **G21 — Without `spectrum`, `realfft` is gone** and no FFT task is
|
||||
spawned; the update stream simply carries no `SpectrumFrame`. Clients
|
||||
(TUI `f`, web) still run; their bars stay dark. `Player::spectrum_tap()`
|
||||
keeps its signature (D8).
|
||||
- [ ] **G22 — Without `web-ui`, no `tonic-web`, no `include_dir`, no embedded
|
||||
bundle**, and the gRPC route still serves native HTTP/2 clients. (Existing
|
||||
behaviour; only re-verified.)
|
||||
- [ ] **G23 — Without `notifications`, `cbd-tui` links no `notify-rust`** and
|
||||
the now-playing path compiles to nothing; every other TUI behaviour is
|
||||
unchanged, including the config key (an ignored key must not break the
|
||||
config parse).
|
||||
|
||||
## Discoverability
|
||||
|
||||
- [ ] **G24 — `features` prints the truth.** `crabidy-server features` and
|
||||
`cbd features` list exactly the compiled providers plus the compiled extras,
|
||||
one per line. *(test: `build_features_lists_providers_then_extras`.)*
|
||||
- [ ] **G25 — The startup log names the build.** One `info!` line lists the
|
||||
compiled features; a `providers` entry this binary cannot mount produces one
|
||||
`warn!` naming it. Neither aborts startup (fail-open library layer).
|
||||
*(test: `unavailable_providers_reports_built_out_and_unknown_names`.)*
|
||||
- [ ] **G26 — The default `crabidy-server.toml` lists only built-in
|
||||
providers**, so a pruned-by-build binary never writes a config full of names
|
||||
it will warn about. *(test:
|
||||
`ensure_default_lists_only_built_in_providers`.)*
|
||||
- [ ] **G27 — The docs describe the flags.** `docs/src/` gains a build-features
|
||||
page (in `SUMMARY.md`) with the feature table, the "what you lose" column,
|
||||
and two worked examples (local-files appliance; tidal + web UI). `README`
|
||||
points at it.
|
||||
|
||||
## Hard rules (always apply)
|
||||
|
||||
- [ ] **G28 — No panics on input or environment.** No `unwrap`/`expect` added
|
||||
on config, path, or stream data in any gated code; a missing feature is an
|
||||
error value or an absent mount, never a panic. `todo!()`/`unimplemented!()`
|
||||
from the stub stage are all gone.
|
||||
- [ ] **G29 — Errors stay typed at the boundaries.** `ProviderError` for
|
||||
provider paths, `CaptureError`/`Status` for RPCs, `Box<dyn Error>` only in
|
||||
CLI entry points. No `color-eyre` report reaches a client.
|
||||
- [ ] **G30 — No secrets in the new paths.** The `features` output and the
|
||||
startup lines carry feature names only — no config values, no credentials.
|
||||
Loading…
Reference in New Issue