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"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
url = "2"
|
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" }
|
absdy = { path = "absdy" }
|
||||||
audio-player = { path = "audio-player" }
|
audio-player = { path = "audio-player", default-features = false }
|
||||||
cbd-cli = { path = "cbd-cli" }
|
cbd-cli = { path = "cbd-cli" }
|
||||||
cbd-tui = { path = "cbd-tui" }
|
cbd-tui = { path = "cbd-tui", default-features = false }
|
||||||
crabidy-core = { path = "crabidy-core" }
|
crabidy-core = { path = "crabidy-core" }
|
||||||
crabidy-server = { path = "crabidy-server" }
|
crabidy-server = { path = "crabidy-server", default-features = false }
|
||||||
fsdy = { path = "fsdy" }
|
fsdy = { path = "fsdy" }
|
||||||
fyyd = { path = "fyyd" }
|
fyyd = { path = "fyyd" }
|
||||||
jamendody = { path = "jamendody" }
|
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
|
version.workspace = true
|
||||||
edition.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]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
|
|
@ -11,8 +21,8 @@ futures.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rodio.workspace = true
|
rodio.workspace = true
|
||||||
stream-download.workspace = true
|
stream-download.workspace = true
|
||||||
symphonia.workspace = true
|
symphonia = { workspace = true, optional = true }
|
||||||
symphonia-adapter-libopus.workspace = true
|
symphonia-adapter-libopus = { workspace = true, optional = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
mod hls;
|
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 opus_source;
|
||||||
mod player;
|
mod player;
|
||||||
mod player_engine;
|
mod player_engine;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use stream_download::storage::temp::TempStorageProvider;
|
||||||
use stream_download::{Settings, StreamDownload};
|
use stream_download::{Settings, StreamDownload};
|
||||||
|
|
||||||
use crate::hls::{HlsParams, HlsStream};
|
use crate::hls::{HlsParams, HlsStream};
|
||||||
|
#[cfg(feature = "opus")]
|
||||||
use crate::opus_source::{is_ogg_opus, OpusSource};
|
use crate::opus_source::{is_ogg_opus, OpusSource};
|
||||||
use crate::spectrum_tap::{SpectrumTap, TappingSource};
|
use crate::spectrum_tap::{SpectrumTap, TappingSource};
|
||||||
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
||||||
|
|
@ -408,6 +409,7 @@ impl PlayerEngine {
|
||||||
.seek(SeekFrom::Start(0))
|
.seek(SeekFrom::Start(0))
|
||||||
.context("failed to rewind after sniffing the header")?;
|
.context("failed to rewind after sniffing the header")?;
|
||||||
|
|
||||||
|
#[cfg(feature = "opus")]
|
||||||
if is_ogg_opus(&header[..n]) {
|
if is_ogg_opus(&header[..n]) {
|
||||||
debug!("decoding Ogg-Opus via libopus");
|
debug!("decoding Ogg-Opus via libopus");
|
||||||
let source = OpusSource::new(reader, byte_len, seekable)?;
|
let source = OpusSource::new(reader, byte_len, seekable)?;
|
||||||
|
|
@ -416,6 +418,18 @@ impl PlayerEngine {
|
||||||
Box::new(TappingSource::new(source, self.spectrum.clone()));
|
Box::new(TappingSource::new(source, self.spectrum.clone()));
|
||||||
return Ok((tapped, duration));
|
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
|
// Symphonia probes the container length during init; without a known
|
||||||
// byte length it seeks from the end, which rodio 0.22 turns into an
|
// byte length it seeks from the end, which rodio 0.22 turns into an
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,8 @@ pub enum ServerCommand {
|
||||||
Global(GlobalCmd),
|
Global(GlobalCmd),
|
||||||
/// List audio output devices, or set `[audio] device` if one is given.
|
/// List audio output devices, or set `[audio] device` if one is given.
|
||||||
AudioDevices(AudioDevicesArgs),
|
AudioDevices(AudioDevicesArgs),
|
||||||
|
/// Print the build features this binary was compiled with.
|
||||||
|
Features,
|
||||||
/// Print a shell completion script.
|
/// Print a shell completion script.
|
||||||
Completions(CompletionsArgs),
|
Completions(CompletionsArgs),
|
||||||
}
|
}
|
||||||
|
|
@ -262,6 +264,8 @@ pub enum CbdCommand {
|
||||||
Global(GlobalCmd),
|
Global(GlobalCmd),
|
||||||
/// List audio output devices, or set `[audio] device` if one is given.
|
/// List audio output devices, or set `[audio] device` if one is given.
|
||||||
AudioDevices(AudioDevicesArgs),
|
AudioDevices(AudioDevicesArgs),
|
||||||
|
/// Print the build features this binary was compiled with.
|
||||||
|
Features,
|
||||||
/// Print a shell completion script.
|
/// Print a shell completion script.
|
||||||
Completions(CompletionsArgs),
|
Completions(CompletionsArgs),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ name = "cbd-tui"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.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]
|
[dependencies]
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
cbd-cli = { workspace = true, features = ["client"] }
|
cbd-cli = { workspace = true, features = ["client"] }
|
||||||
|
|
@ -12,7 +19,7 @@ clap.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
notify-rust.workspace = true
|
notify-rust = { workspace = true, optional = true }
|
||||||
ratatui.workspace = true
|
ratatui.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::{ops::Div, time::Duration};
|
use std::{ops::Div, time::Duration};
|
||||||
|
|
||||||
|
#[cfg(feature = "notifications")]
|
||||||
use notify_rust::Notification;
|
use notify_rust::Notification;
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
|
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
|
||||||
|
|
@ -84,30 +85,7 @@ impl NowPlaying {
|
||||||
}
|
}
|
||||||
pub fn update_track(&mut self, active: Option<Track>) {
|
pub fn update_track(&mut self, active: Option<Track>) {
|
||||||
if let Some(track) = &active {
|
if let Some(track) = &active {
|
||||||
let body = if let Some(ref album) = track.album {
|
notify_now_playing(track);
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.track = active;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,31 @@ name = "cbd"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.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]
|
[dependencies]
|
||||||
cbd-cli = { workspace = true, features = ["client"] }
|
cbd-cli = { workspace = true, features = ["client"] }
|
||||||
cbd-tui.workspace = true
|
cbd-tui = { workspace = true, default-features = false }
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
crabidy-server.workspace = true
|
crabidy-server = { workspace = true, default-features = false }
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ async fn run_command(
|
||||||
CbdCommand::Guard(args) => server_cli::guard(args).await,
|
CbdCommand::Guard(args) => server_cli::guard(args).await,
|
||||||
CbdCommand::Scan(args) => server_cli::scan(args).await,
|
CbdCommand::Scan(args) => server_cli::scan(args).await,
|
||||||
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
|
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
|
||||||
|
CbdCommand::Features => server_cli::features(),
|
||||||
CbdCommand::Auth(args) => {
|
CbdCommand::Auth(args) => {
|
||||||
let password = args
|
let password = args
|
||||||
.password
|
.password
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,48 @@ edition.workspace = true
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
path = "src/main.rs"
|
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]
|
[features]
|
||||||
# The embedded web client (architecture/web-client.md). On by default;
|
default = ["all-providers", "opus", "spectrum", "web-ui"]
|
||||||
# disable for a headless-only binary without the bundle.
|
|
||||||
default = ["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"]
|
web-ui = ["dep:tonic-web", "dep:include_dir"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
@ -19,31 +57,33 @@ argon2.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
blake3.workspace = true
|
blake3 = { workspace = true, optional = true }
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
include_dir = { workspace = true, optional = true }
|
include_dir = { workspace = true, optional = true }
|
||||||
realfft.workspace = true
|
realfft = { workspace = true, optional = true }
|
||||||
tonic-web = { workspace = true, optional = true }
|
tonic-web = { workspace = true, optional = true }
|
||||||
tower.workspace = 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
|
# The `client` feature pulls in the gRPC executor used by the
|
||||||
# library/queue/global subcommands (architecture/cli.md D1/D3).
|
# library/queue/global subcommands (architecture/cli.md D1/D3).
|
||||||
cbd-cli = { workspace = true, features = ["client"] }
|
cbd-cli = { workspace = true, features = ["client"] }
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
absdy.workspace = true
|
absdy = { workspace = true, optional = true }
|
||||||
fsdy.workspace = true
|
fsdy = { workspace = true, optional = true }
|
||||||
fyyd.workspace = true
|
fyyd = { workspace = true, optional = true }
|
||||||
jamendody.workspace = true
|
jamendody = { workspace = true, optional = true }
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest = { workspace = true, optional = true }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
soundclouddy.workspace = true
|
soundclouddy = { workspace = true, optional = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tidaldy.workspace = true
|
tidaldy = { workspace = true, optional = true }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
|
|
@ -51,7 +91,7 @@ tonic = { workspace = true, features = ["router", "transport", "codegen"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
ytdy.workspace = true
|
ytdy = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ use std::io::{IsTerminal, Read};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use cbd_cli::{GuardArgs, Role, ScanArgs};
|
use cbd_cli::{GuardArgs, Role, ScanArgs};
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use crate::crabidy_store::CrabidyStore;
|
use crate::crabidy_store::CrabidyStore;
|
||||||
use crate::settings::ServerSettings;
|
use crate::settings::ServerSettings;
|
||||||
|
|
||||||
|
|
@ -19,8 +21,22 @@ use crate::settings::ServerSettings;
|
||||||
pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051";
|
pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051";
|
||||||
|
|
||||||
/// File-name extensions treated as playable audio by `scan` (lowercased).
|
/// 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] = &[
|
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`).
|
/// The crabidy config directory (`dirs::config_dir()/crabidy`).
|
||||||
|
|
@ -178,7 +194,59 @@ pub fn audio_devices(select: Option<String>) -> Result<(), Box<dyn Error>> {
|
||||||
Ok(())
|
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.
|
/// The outcome of a `scan` walk, for a concise summary and for tests.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[derive(Debug, Default, PartialEq, Eq)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
pub struct ScanSummary {
|
pub struct ScanSummary {
|
||||||
/// Sidecar `.cbd-track.toml` files written this run.
|
/// 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
|
/// `scan <path> [--capture|--move]`: index a music folder (architecture/cli.md
|
||||||
/// D5). Opens the content store only when a capture/move is requested.
|
/// 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>> {
|
pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> {
|
||||||
if !args.path.is_dir() {
|
if !args.path.is_dir() {
|
||||||
return Err(format!("not a directory: {}", args.path.display()).into());
|
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
|
/// the sidecar points at the store entry; otherwise the sidecar's playable is
|
||||||
/// a relative [`fsdy::Playable::File`]. An existing sidecar is never
|
/// a relative [`fsdy::Playable::File`]. An existing sidecar is never
|
||||||
/// clobbered. Unreadable entries are warnings, not failures.
|
/// clobbered. Unreadable entries are warnings, not failures.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub async fn scan_dir(
|
pub async fn scan_dir(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
store: Option<&CrabidyStore>,
|
store: Option<&CrabidyStore>,
|
||||||
|
|
@ -270,6 +340,7 @@ pub async fn scan_dir(
|
||||||
|
|
||||||
/// Indexes one audio file. Returns `true` when a sidecar was written, `false`
|
/// Indexes one audio file. Returns `true` when a sidecar was written, `false`
|
||||||
/// when one already existed (left untouched).
|
/// when one already existed (left untouched).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
async fn scan_file(
|
async fn scan_file(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
file: &Path,
|
file: &Path,
|
||||||
|
|
@ -324,6 +395,7 @@ async fn scan_file(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `path`'s extension is a known audio extension (case-insensitive).
|
/// Whether `path`'s extension is a known audio extension (case-insensitive).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
fn is_audio_file(path: &Path) -> bool {
|
fn is_audio_file(path: &Path) -> bool {
|
||||||
path.extension()
|
path.extension()
|
||||||
.and_then(|ext| ext.to_str())
|
.and_then(|ext| ext.to_str())
|
||||||
|
|
@ -360,6 +432,63 @@ mod tests {
|
||||||
assert!(ServerSettings::load(dir.path()).is_ok());
|
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]
|
#[tokio::test]
|
||||||
async fn scan_writes_file_playables_and_skips_existing_tomls() {
|
async fn scan_writes_file_playables_and_skips_existing_tomls() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -389,6 +518,7 @@ mod tests {
|
||||||
assert_eq!(again.skipped, 1);
|
assert_eq!(again.skipped, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() {
|
async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() {
|
||||||
let src = TempDir::new().expect("srcdir");
|
let src = TempDir::new().expect("srcdir");
|
||||||
|
|
@ -415,6 +545,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scan_move_removes_the_source_audio() {
|
async fn scan_move_removes_the_source_audio() {
|
||||||
let src = TempDir::new().expect("srcdir");
|
let src = TempDir::new().expect("srcdir");
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,22 @@ pub mod auth;
|
||||||
#[cfg(feature = "web-ui")]
|
#[cfg(feature = "web-ui")]
|
||||||
pub mod web;
|
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 capture;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub mod crabidy_store;
|
pub mod crabidy_store;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub mod orphans;
|
pub mod orphans;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
#[cfg(feature = "spectrum")]
|
||||||
pub mod spectrum;
|
pub mod spectrum;
|
||||||
|
|
||||||
use audio_player::PlayerMessage;
|
use audio_player::PlayerMessage;
|
||||||
|
|
@ -56,6 +64,17 @@ pub async fn serve(
|
||||||
if authenticator.enabled() {
|
if authenticator.enabled() {
|
||||||
info!("role authorization 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 toggles = server_settings.provider_toggles();
|
||||||
|
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
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);
|
// Queue persistence rides on the /crabidy store (its `current` folder);
|
||||||
// the orchestrator built it, so playback shares the same Arc. Without a
|
// 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 crabidy_store = orchestrator.crabidy_store();
|
||||||
|
|
||||||
let playback = playback::Playback::new(
|
let playback = playback::Playback::new(
|
||||||
update_tx.clone(),
|
update_tx.clone(),
|
||||||
orchestrator.provider_tx.clone(),
|
orchestrator.provider_tx.clone(),
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
crabidy_store,
|
crabidy_store,
|
||||||
server_settings.audio.device.clone(),
|
server_settings.audio.device.clone(),
|
||||||
);
|
);
|
||||||
// Reload the persisted current queue before anything can observe or
|
// Reload the persisted current queue before anything can observe or
|
||||||
// mutate state; never starts playback.
|
// mutate state; never starts playback.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
playback.restore_current().await;
|
playback.restore_current().await;
|
||||||
|
|
||||||
let playback_tx = playback.playback_tx.clone();
|
let playback_tx = playback.playback_tx.clone();
|
||||||
|
|
@ -89,6 +112,7 @@ pub async fn serve(
|
||||||
});
|
});
|
||||||
info!("player message forwarder started");
|
info!("player message forwarder started");
|
||||||
|
|
||||||
|
#[cfg(feature = "spectrum")]
|
||||||
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
||||||
|
|
||||||
let crabidy_service = rpc::RpcService::new(
|
let crabidy_service = rpc::RpcService::new(
|
||||||
|
|
@ -147,6 +171,7 @@ pub fn build_router(
|
||||||
/// subscribers, and only recomputes when the tap advanced since the
|
/// subscribers, and only recomputes when the tap advanced since the
|
||||||
/// last tick (audio is flowing), emitting a single zero frame when
|
/// last tick (audio is flowing), emitting a single zero frame when
|
||||||
/// playback goes idle so the bars fall rather than freeze.
|
/// playback goes idle so the bars fall rather than freeze.
|
||||||
|
#[cfg(feature = "spectrum")]
|
||||||
fn spawn_spectrum_task(
|
fn spawn_spectrum_task(
|
||||||
tap: std::sync::Arc<audio_player::SpectrumTap>,
|
tap: std::sync::Arc<audio_player::SpectrumTap>,
|
||||||
update_tx: tokio::sync::broadcast::Sender<
|
update_tx: tokio::sync::broadcast::Sender<
|
||||||
|
|
@ -906,6 +931,10 @@ pub enum ProviderCommand {
|
||||||
/// on `progress_tx`, ending in exactly one `finished` event (with
|
/// on `progress_tx`, ending in exactly one `finished` event (with
|
||||||
/// `error` set on failure). A rejected capture answers with the error
|
/// `error` set on failure). A rejected capture answers with the error
|
||||||
/// and sends no progress events.
|
/// 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 {
|
CaptureLibraryNode {
|
||||||
path: String,
|
path: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -924,6 +953,7 @@ impl ProviderCommand {
|
||||||
Self::CreateLibraryNode { .. } => "create_library_node",
|
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||||
Self::RenameLibraryNode { .. } => "rename_library_node",
|
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||||||
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -989,6 +1019,10 @@ pub enum PlaybackCommand {
|
||||||
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||||||
/// snapshot is consistent; the disk write happens on a spawned task and
|
/// snapshot is consistent; the disk write happens on a spawned task and
|
||||||
/// reports through `result_tx`.
|
/// 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 {
|
SaveQueue {
|
||||||
name: String,
|
name: String,
|
||||||
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
||||||
|
|
@ -1032,6 +1066,7 @@ impl PlaybackCommand {
|
||||||
Self::ResolveFinished { .. } => "resolve_finished",
|
Self::ResolveFinished { .. } => "resolve_finished",
|
||||||
Self::Clear { .. } => "clear",
|
Self::Clear { .. } => "clear",
|
||||||
Self::SetCurrent { .. } => "set_current",
|
Self::SetCurrent { .. } => "set_current",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
Self::SaveQueue { .. } => "save_queue",
|
Self::SaveQueue { .. } => "save_queue",
|
||||||
Self::ToggleShuffle => "toggle_shuffle",
|
Self::ToggleShuffle => "toggle_shuffle",
|
||||||
Self::ToggleRepeat => "toggle_repeat",
|
Self::ToggleRepeat => "toggle_repeat",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ async fn run_command(
|
||||||
ServerCommand::Guard(args) => cli::guard(args).await,
|
ServerCommand::Guard(args) => cli::guard(args).await,
|
||||||
ServerCommand::Scan(args) => cli::scan(args).await,
|
ServerCommand::Scan(args) => cli::scan(args).await,
|
||||||
ServerCommand::AudioDevices(args) => cli::audio_devices(args.device),
|
ServerCommand::AudioDevices(args) => cli::audio_devices(args.device),
|
||||||
|
ServerCommand::Features => cli::features(),
|
||||||
ServerCommand::Library(cmd) => {
|
ServerCommand::Library(cmd) => {
|
||||||
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await
|
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;
|
use crate::capture::CaptureError;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot};
|
use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot};
|
||||||
use crate::{PendingResolve, QueueManager, ResolveKind};
|
use crate::{PendingResolve, QueueManager, ResolveKind};
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
|
|
@ -11,8 +16,12 @@ use crabidy_core::proto::crabidy::{
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
#[cfg(feature = "fs")]
|
||||||
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument};
|
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 {
|
pub struct Playback {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
|
|
@ -28,9 +37,11 @@ pub struct Playback {
|
||||||
next_op_id: AtomicU64,
|
next_op_id: AtomicU64,
|
||||||
/// `None` when queue persistence is disabled (no usable state
|
/// `None` when queue persistence is disabled (no usable state
|
||||||
/// directory) — the queue then lives in memory only.
|
/// directory) — the queue then lives in memory only.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
store: Option<Arc<CrabidyStore>>,
|
store: Option<Arc<CrabidyStore>>,
|
||||||
/// Feeds the persister task; latest snapshot wins, so the loop never
|
/// Feeds the persister task; latest snapshot wins, so the loop never
|
||||||
/// waits on disk (architecture/queue-persistence.md D4).
|
/// waits on disk (architecture/queue-persistence.md D4).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
}
|
}
|
||||||
|
|
@ -39,12 +50,13 @@ impl Playback {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
store: Option<Arc<CrabidyStore>>,
|
#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>,
|
||||||
audio_device: Option<String>,
|
audio_device: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (playback_tx, playback_rx) = flume::bounded(64);
|
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||||
let queue = Mutex::new(QueueManager::new());
|
let queue = Mutex::new(QueueManager::new());
|
||||||
let state = Mutex::new(PlayState::Stopped);
|
let state = Mutex::new(PlayState::Stopped);
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
||||||
let player = Player::new(audio_device);
|
let player = Player::new(audio_device);
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -56,7 +68,9 @@ impl Playback {
|
||||||
state,
|
state,
|
||||||
pending: Mutex::new(HashMap::new()),
|
pending: Mutex::new(HashMap::new()),
|
||||||
next_op_id: AtomicU64::new(0),
|
next_op_id: AtomicU64::new(0),
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
store,
|
store,
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
persist_tx,
|
persist_tx,
|
||||||
player,
|
player,
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +80,7 @@ impl Playback {
|
||||||
/// shuffle/repeat. Never starts playback — a restarted server stays
|
/// shuffle/repeat. Never starts playback — a restarted server stays
|
||||||
/// silent. Call before [`Self::run`] so nothing observes the empty
|
/// silent. Call before [`Self::run`] so nothing observes the empty
|
||||||
/// queue first.
|
/// queue first.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub async fn restore_current(&self) {
|
pub async fn restore_current(&self) {
|
||||||
let Some(store) = &self.store else {
|
let Some(store) = &self.store else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -97,6 +112,7 @@ impl Playback {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
if let Some(store) = &self.store {
|
if let Some(store) = &self.store {
|
||||||
crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +273,7 @@ impl Playback {
|
||||||
self.play(track).await;
|
self.play(track).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
PlaybackCommand::SaveQueue { name, result_tx } => {
|
PlaybackCommand::SaveQueue { name, result_tx } => {
|
||||||
debug!(name, "saving the queue");
|
debug!(name, "saving the queue");
|
||||||
// Snapshot on the loop (single-writer discipline), write on
|
// 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
|
/// when persistence is disabled. Latest snapshot wins, so calling this
|
||||||
/// on every mutation is free of backpressure (the persister skips
|
/// on every mutation is free of backpressure (the persister skips
|
||||||
/// writes for unchanged snapshots).
|
/// 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) {
|
fn send_persist_snapshot(&self, queue: &QueueManager) {
|
||||||
if self.store.is_none() {
|
if self.store.is_none() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -844,6 +867,7 @@ impl Playback {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn track(i: usize) -> Track {
|
fn track(i: usize) -> Track {
|
||||||
|
|
@ -859,6 +883,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> {
|
async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> {
|
||||||
Arc::new(
|
Arc::new(
|
||||||
CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
|
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 (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
let (provider_tx, _provider_rx) = flume::bounded(16);
|
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) {
|
fn fill_queue(playback: &Playback, n: usize) {
|
||||||
let tracks: Vec<Track> = (0..n).map(track).collect();
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
let mut queue = playback.queue.lock().expect("queue lock");
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
let _ = queue.replace_with_tracks(&tracks);
|
let _ = queue.replace_with_tracks(&tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restore_fills_the_queue_without_starting_playback() {
|
async fn restore_fills_the_queue_without_starting_playback() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -908,6 +941,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restore_survives_an_out_of_range_position() {
|
async fn restore_survives_an_out_of_range_position() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -927,6 +961,7 @@ mod tests {
|
||||||
assert_eq!(queue.current_position(), 0);
|
assert_eq!(queue.current_position(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn save_queue_command_snapshots_the_live_queue() {
|
async fn save_queue_command_snapshots_the_live_queue() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -960,6 +995,7 @@ mod tests {
|
||||||
assert_eq!(entries, 2);
|
assert_eq!(entries, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn save_queue_rejects_an_empty_queue() {
|
async fn save_queue_rejects_an_empty_queue() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -982,7 +1018,10 @@ mod tests {
|
||||||
// with `None`. The marked tracks are skipped without any provider
|
// with `None`. The marked tracks are skipped without any provider
|
||||||
// round trip (the provider channel is closed — a call would fail,
|
// round trip (the provider channel is closed — a call would fail,
|
||||||
// not hang).
|
// not hang).
|
||||||
let playback = playback_with(None);
|
let playback = playback_with(
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
None,
|
||||||
|
);
|
||||||
let tracks: Vec<Track> = (0..3)
|
let tracks: Vec<Track> = (0..3)
|
||||||
.map(|i| Track {
|
.map(|i| Track {
|
||||||
is_skipped: true,
|
is_skipped: true,
|
||||||
|
|
@ -1001,6 +1040,7 @@ mod tests {
|
||||||
assert!(urls.is_none(), "an all-skipped queue has nothing playable");
|
assert!(urls.is_none(), "an all-skipped queue has nothing playable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn queue_mutations_reach_the_persist_channel() {
|
async fn queue_mutations_reach_the_persist_channel() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
|
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT};
|
use crate::orphans::{OrphansProvider, ORPHANS_PROVIDER_ROOT};
|
||||||
use crate::settings::ProviderToggles;
|
use crate::settings::ProviderToggles;
|
||||||
use crate::{ProviderCommand, ProviderMessage};
|
use crate::{ProviderCommand, ProviderMessage};
|
||||||
|
|
@ -7,7 +9,11 @@ use crabidy_core::{
|
||||||
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
||||||
ProviderClient, ProviderError,
|
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};
|
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
||||||
|
|
||||||
/// One mounted provider: the library root it owns, the name the root listing
|
/// 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 {
|
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 {
|
fn new(root: &'static str, name: &'static str, client: Arc<dyn ProviderClient>) -> Self {
|
||||||
Self { root, name, client }
|
Self { root, name, client }
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +65,15 @@ impl Mount {
|
||||||
///
|
///
|
||||||
/// `init` may resolve values of its own (a scraped SoundCloud `client_id`, a
|
/// `init` may resolve values of its own (a scraped SoundCloud `client_id`, a
|
||||||
/// refreshed token), so whatever it reports is written back.
|
/// 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>(
|
async fn mount_from_config<C>(
|
||||||
config_dir: &std::path::Path,
|
config_dir: &std::path::Path,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
|
|
@ -102,7 +120,9 @@ pub struct ProviderOrchestrator {
|
||||||
mounts: Vec<Mount>,
|
mounts: Vec<Mount>,
|
||||||
/// The single writer behind `/crabidy`: the content store and the toml
|
/// The single writer behind `/crabidy`: the content store and the toml
|
||||||
/// tree. `None` disables saving/capturing (and the `/crabidy` mount goes
|
/// 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>>,
|
crabidy_store: Option<Arc<CrabidyStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +137,7 @@ impl ProviderOrchestrator {
|
||||||
provider_tx,
|
provider_tx,
|
||||||
provider_rx,
|
provider_rx,
|
||||||
mounts,
|
mounts,
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
crabidy_store: None,
|
crabidy_store: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +164,7 @@ impl ProviderOrchestrator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `/crabidy` store (writer), for saves and the startup restore.
|
/// The `/crabidy` store (writer), for saves and the startup restore.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> {
|
pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> {
|
||||||
self.crabidy_store.clone()
|
self.crabidy_store.clone()
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +241,7 @@ impl ProviderOrchestrator {
|
||||||
error!("failed to send delete_library_node result: {err}");
|
error!("failed to send delete_library_node result: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
ProviderCommand::CaptureLibraryNode {
|
ProviderCommand::CaptureLibraryNode {
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
|
|
@ -296,9 +319,11 @@ impl ProviderOrchestrator {
|
||||||
}
|
}
|
||||||
// Everything that comes up gets pushed here; registration order does
|
// Everything that comes up gets pushed here; registration order does
|
||||||
// not matter, `from_mounts` sorts for the root listing.
|
// 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();
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
// Tidal: skipped when disabled; when enabled a broken config is still
|
// Tidal: skipped when disabled; when enabled a broken config is still
|
||||||
// fatal (unlike the local providers), preserving prior behavior.
|
// fatal (unlike the local providers), preserving prior behavior.
|
||||||
|
#[cfg(feature = "tidal")]
|
||||||
if enabled.tidal {
|
if enabled.tidal {
|
||||||
let config_file = config_dir.join("tidaly.toml");
|
let config_file = config_dir.join("tidaly.toml");
|
||||||
debug!(config_file = %config_file.display(), "loading tidal config");
|
debug!(config_file = %config_file.display(), "loading tidal config");
|
||||||
|
|
@ -318,16 +343,26 @@ impl ProviderOrchestrator {
|
||||||
Arc::new(client),
|
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
|
// The filesystem provider is optional: a broken local config only
|
||||||
// costs the `/fs` subtree, never the server. Kept in a local as well
|
// costs the `/fs` subtree, never the server. Kept in a local as well
|
||||||
// as a mount: `/orphans` needs its disk root below.
|
// 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_client = if enabled.fs {
|
||||||
let fs_config_file = config_dir.join("fsdy.toml");
|
let fs_config_file = config_dir.join("fsdy.toml");
|
||||||
debug!(config_file = %fs_config_file.display(), "loading fs config");
|
debug!(config_file = %fs_config_file.display(), "loading fs config");
|
||||||
let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
|
let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
|
||||||
match fsdy::Client::init(&raw_fs_settings).await {
|
match fsdy::Client::init(&raw_fs_settings).await {
|
||||||
Ok(client) => {
|
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}");
|
error!("failed to write fsdy config file: {err}");
|
||||||
}
|
}
|
||||||
Some(Arc::new(client))
|
Some(Arc::new(client))
|
||||||
|
|
@ -411,6 +446,8 @@ impl ProviderOrchestrator {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crabidy_store
|
||||||
|
};
|
||||||
// The remaining providers all follow the same non-fatal shape — read
|
// The remaining providers all follow the same non-fatal shape — read
|
||||||
// the config file, init, write back what init resolved, mount on
|
// the config file, init, write back what init resolved, mount on
|
||||||
// success — so they go through one helper:
|
// success — so they go through one helper:
|
||||||
|
|
@ -424,6 +461,7 @@ impl ProviderOrchestrator {
|
||||||
// (architecture/soundcloud-provider.md D1).
|
// (architecture/soundcloud-provider.md D1).
|
||||||
// - Jamendo: needs a registered `client_id`; without one it is
|
// - Jamendo: needs a registered `client_id`; without one it is
|
||||||
// disabled (architecture/jamendo-provider.md D1).
|
// disabled (architecture/jamendo-provider.md D1).
|
||||||
|
#[cfg(feature = "youtube")]
|
||||||
if enabled.youtube {
|
if enabled.youtube {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
mount_from_config::<ytdy::Client>(
|
mount_from_config::<ytdy::Client>(
|
||||||
|
|
@ -435,6 +473,7 @@ impl ProviderOrchestrator {
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "fyyd")]
|
||||||
if enabled.fyyd {
|
if enabled.fyyd {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
mount_from_config::<fyyd::Client>(
|
mount_from_config::<fyyd::Client>(
|
||||||
|
|
@ -446,6 +485,7 @@ impl ProviderOrchestrator {
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "abs")]
|
||||||
if enabled.abs {
|
if enabled.abs {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
mount_from_config::<absdy::Client>(
|
mount_from_config::<absdy::Client>(
|
||||||
|
|
@ -457,6 +497,7 @@ impl ProviderOrchestrator {
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "soundcloud")]
|
||||||
if enabled.soundcloud {
|
if enabled.soundcloud {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
mount_from_config::<soundclouddy::Client>(
|
mount_from_config::<soundclouddy::Client>(
|
||||||
|
|
@ -468,6 +509,7 @@ impl ProviderOrchestrator {
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "jamendo")]
|
||||||
if enabled.jamendo {
|
if enabled.jamendo {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
mount_from_config::<jamendody::Client>(
|
mount_from_config::<jamendody::Client>(
|
||||||
|
|
@ -480,6 +522,7 @@ impl ProviderOrchestrator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
crabidy_store,
|
crabidy_store,
|
||||||
..Self::from_mounts(mounts)
|
..Self::from_mounts(mounts)
|
||||||
})
|
})
|
||||||
|
|
@ -541,15 +584,21 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
debug!("serving global library root");
|
debug!("serving global library root");
|
||||||
return Ok(self.get_lib_root());
|
return Ok(self.get_lib_root());
|
||||||
}
|
}
|
||||||
let mut node = self
|
let node = self
|
||||||
.owner_or(path, ProviderError::MalformedPath)?
|
.owner_or(path, ProviderError::MalformedPath)?
|
||||||
.get_lib_node(path)
|
.get_lib_node(path)
|
||||||
.await?;
|
.await?;
|
||||||
// Mark tracks already held in the content store (D3/D8) — cheap, and
|
// 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 {
|
if let Some(store) = &self.crabidy_store {
|
||||||
store.annotate_captured(&mut node).await;
|
store.annotate_captured(&mut node).await;
|
||||||
}
|
}
|
||||||
|
node
|
||||||
|
};
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use crate::capture::CaptureError;
|
use crate::capture::CaptureError;
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
|
|
@ -18,10 +19,18 @@ use crabidy_core::proto::crabidy::{
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use std::pin::Pin;
|
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
|
/// 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 →
|
/// queue-save RPCs). Bad input → `invalid_argument`; an existing save name →
|
||||||
/// `already_exists`; precondition failures → `failed_precondition`;
|
/// `already_exists`; precondition failures → `failed_precondition`;
|
||||||
/// everything else → `internal` (logged, message not leaked).
|
/// everything else → `internal` (logged, message not leaked).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status {
|
fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status {
|
||||||
match err {
|
match err {
|
||||||
CaptureError::InvalidName(_) | CaptureError::BadSource(_) => {
|
CaptureError::InvalidName(_) | CaptureError::BadSource(_) => {
|
||||||
|
|
@ -425,6 +434,14 @@ impl CrabidyService for RpcService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<CaptureLibraryNodeRequest>,
|
request: Request<CaptureLibraryNodeRequest>,
|
||||||
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
) -> 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 {
|
let CaptureLibraryNodeRequest {
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
|
|
@ -468,6 +485,7 @@ impl CrabidyService for RpcService {
|
||||||
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
|
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
|
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
|
||||||
/// gesture (architecture/crabidy-store.md D5). A bookmark of the live
|
/// gesture (architecture/crabidy-store.md D5). A bookmark of the live
|
||||||
|
|
@ -483,6 +501,14 @@ impl CrabidyService for RpcService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<SaveQueueRequest>,
|
request: Request<SaveQueueRequest>,
|
||||||
) -> Result<Response<SaveQueueResponse>, Status> {
|
) -> 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;
|
let name = request.into_inner().name;
|
||||||
tracing::Span::current().record("name", name.as_str());
|
tracing::Span::current().record("name", name.as_str());
|
||||||
debug!("received save_queue request");
|
debug!("received save_queue request");
|
||||||
|
|
@ -498,6 +524,7 @@ impl CrabidyService for RpcService {
|
||||||
Err(err) => Err(capture_error_status(err, "cannot save the queue")),
|
Err(err) => Err(capture_error_status(err, "cannot save the queue")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn toggle_play(
|
async fn toggle_play(
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,14 @@ use serde::{Deserialize, Serialize};
|
||||||
/// The server config file name inside the crabidy config directory.
|
/// The server config file name inside the crabidy config directory.
|
||||||
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||||
|
|
||||||
/// Every built-in provider, in the order the default config lists them. Each
|
/// Every provider name the runtime toggle understands, in the order the
|
||||||
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`,
|
/// default config lists them. Each name is a library root (`/tidal`,
|
||||||
/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the
|
/// `/youtube`, `/fyyd`, `/abs`, `/soundcloud`, `/jamendo`, `/fs`, `/crabidy`,
|
||||||
/// store, so it needs `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] = [
|
pub const ALL_PROVIDERS: [&str; 9] = [
|
||||||
"tidal",
|
"tidal",
|
||||||
"youtube",
|
"youtube",
|
||||||
|
|
@ -31,6 +35,32 @@ pub const ALL_PROVIDERS: [&str; 9] = [
|
||||||
"orphans",
|
"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`.
|
/// Contents of `crabidy-server.toml`.
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|
@ -86,19 +116,23 @@ pub struct ProviderToggles {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderToggles {
|
impl ProviderToggles {
|
||||||
/// Every provider on — the default, and what a missing/keyless config
|
/// Every provider this binary has on — the default, and what a
|
||||||
/// yields.
|
/// 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 {
|
pub fn all() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tidal: true,
|
tidal: cfg!(feature = "tidal"),
|
||||||
youtube: true,
|
youtube: cfg!(feature = "youtube"),
|
||||||
fyyd: true,
|
fyyd: cfg!(feature = "fyyd"),
|
||||||
abs: true,
|
abs: cfg!(feature = "abs"),
|
||||||
soundcloud: true,
|
soundcloud: cfg!(feature = "soundcloud"),
|
||||||
jamendo: true,
|
jamendo: cfg!(feature = "jamendo"),
|
||||||
fs: true,
|
fs: cfg!(feature = "fs"),
|
||||||
crabidy: true,
|
// Both live on the content store, which the `fs` feature brings
|
||||||
orphans: true,
|
// (D5).
|
||||||
|
crabidy: cfg!(feature = "fs"),
|
||||||
|
orphans: cfg!(feature = "fs"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -186,14 +220,35 @@ impl ServerSettings {
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the named provider is enabled: every provider when the
|
/// Whether the named provider is enabled: every *built-in* provider when
|
||||||
/// `providers` key is absent, otherwise only the names the list holds.
|
/// 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 {
|
pub fn provider_enabled(&self, name: &str) -> bool {
|
||||||
self.providers
|
BUILT_IN_PROVIDERS.contains(&name)
|
||||||
|
&& self
|
||||||
|
.providers
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|list| list.iter().any(|p| p == name))
|
.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
|
/// The per-provider mount decisions for the orchestrator. `orphans` also
|
||||||
/// requires `crabidy` (it is a view over the store); that dependency is
|
/// 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.
|
/// 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
|
/// Writes a default `crabidy-server.toml` — every provider this binary has
|
||||||
/// — when none exists yet, so users have a full list to prune. A file that
|
/// ([`BUILT_IN_PROVIDERS`]), no auth — when none exists yet, so users have
|
||||||
/// already exists (even a pruned one) is left untouched. Best-effort: the
|
/// a full list to prune. A file that already exists (even a pruned one) is
|
||||||
/// caller treats a write failure as a warning, not a startup error.
|
/// 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> {
|
pub fn ensure_default(config_dir: &Path) -> Result<(), String> {
|
||||||
let file = config_dir.join(SETTINGS_FILE);
|
let file = config_dir.join(SETTINGS_FILE);
|
||||||
match std::fs::metadata(&file) {
|
match std::fs::metadata(&file) {
|
||||||
|
|
@ -223,7 +279,7 @@ impl ServerSettings {
|
||||||
Err(err) => return Err(format!("cannot check {}: {err}", file.display())),
|
Err(err) => return Err(format!("cannot check {}: {err}", file.display())),
|
||||||
}
|
}
|
||||||
let settings = ServerSettings {
|
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(),
|
auth: AuthSettings::default(),
|
||||||
audio: AudioSettings::default(),
|
audio: AudioSettings::default(),
|
||||||
};
|
};
|
||||||
|
|
@ -370,17 +426,78 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_absent_providers_key_enables_everything() {
|
fn an_absent_providers_key_enables_every_built_in_provider() {
|
||||||
let settings = ServerSettings::default();
|
let settings = ServerSettings::default();
|
||||||
for provider in ALL_PROVIDERS {
|
for provider in BUILT_IN_PROVIDERS {
|
||||||
assert!(settings.provider_enabled(provider), "{provider}");
|
assert!(settings.provider_enabled(provider), "{provider}");
|
||||||
}
|
}
|
||||||
let toggles = settings.provider_toggles();
|
// …and nothing this binary was built without.
|
||||||
assert!(
|
for provider in ALL_PROVIDERS {
|
||||||
toggles.tidal && toggles.youtube && toggles.fs && toggles.crabidy && toggles.orphans
|
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]
|
#[test]
|
||||||
fn an_explicit_list_enables_only_its_names() {
|
fn an_explicit_list_enables_only_its_names() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -404,7 +521,7 @@ mod tests {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
ServerSettings::ensure_default(dir.path()).expect("write default");
|
ServerSettings::ensure_default(dir.path()).expect("write default");
|
||||||
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
|
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!(text.contains(provider), "default lists {provider}");
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -414,7 +531,7 @@ mod tests {
|
||||||
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reloaded.providers.as_ref().map(Vec::len),
|
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.
|
// 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");
|
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
|
in
|
||||||
{
|
{
|
||||||
imports = [ ./devenv-rust.nix ];
|
imports = [
|
||||||
|
./devenv-rust.nix
|
||||||
|
./devenv-docs.nix
|
||||||
|
];
|
||||||
|
|
||||||
# The wasm target for cbd-web; merges with the languages.rust
|
# The wasm target for cbd-web; merges with the languages.rust
|
||||||
# settings in devenv-rust.nix.
|
# settings in devenv-rust.nix.
|
||||||
|
|
@ -80,6 +83,51 @@ in
|
||||||
scripts.gen-cli-assets.exec = ''
|
scripts.gen-cli-assets.exec = ''
|
||||||
cd "$DEVENV_ROOT" && CBD_ASSET_DIR="$DEVENV_ROOT/dist" cargo build "$@"
|
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 = "";
|
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