Compare commits

..

No commits in common. "fable" and "main" have entirely different histories.
fable ... main

245 changed files with 4788 additions and 63778 deletions

33
.gitignore vendored
View File

@ -1,34 +1 @@
target
/target
# generated CLI assets (gen-cli-assets writes here)
/dist
# devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit / git-hooks
.pre-commit-config.yaml
.vim
AGENTS.md
CLAUDE.md
.mcp.json
.codex/
opencode.json
.claude/
.agents/
.opencode/
.explained/
*.kickstart-new
# mdbook build output
docs/book/
# audiobookshelf API key (secret, do not commit)
abs-api-key

5648
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,136 +1,9 @@
[workspace]
resolver = "2"
members = [
"absdy",
"audio-player",
"cbd",
"cbd-cli",
"cbd-tui",
"cbd-web",
"crabidy-core",
"crabidy-server",
"fsdy",
"fyyd",
"jamendody",
"rssdy",
"soundclouddy",
"stream-download",
"tidaldy",
"ytdy",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
# This root manifest is a pure workspace, so crane finds no `[package].name` to
# name its derivations after and warns that it is using a placeholder
# ("cargo-package-0.1.0.drv" in the build log). Name it here instead.
[workspace.metadata.crane]
name = "crabidy"
[workspace.dependencies]
anyhow = "1"
argon2 = { version = "0.5", features = ["std"] }
async-trait = "0.1"
base64 = "0.22"
blake3 = "1"
bytes = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
axum = "0.8"
clap = { version = "4", features = ["derive"] }
clap-serde-derive = "0.2"
clap_complete = "4"
clap_mangen = "0.2"
console_error_panic_hook = "0.1"
crossterm = "0.29"
dirs = "6"
flume = "0.12"
futures = "0.3"
gloo-timers = { version = "0.3", features = ["futures"] }
http = "1"
include_dir = "0.7"
# `dup2` only: the TUI points stderr at a file so C-library writes (ALSA
# underrun messages) cannot scribble on the interface (cbd-tui/src/stderr.rs).
libc = "0.2"
leptos = { version = "0.8", default-features = false, features = ["csr"] }
notify-rust = "4"
# The MPRIS D-Bus surface of cbd-tui. Built on zbus, which speaks the
# protocol in pure Rust — no libdbus, so nothing to install or link.
mpris-server = "0.10"
feed-rs = "2"
percent-encoding = "2"
prost = "0.14"
rand = "0.10"
realfft = "3"
ratatui = "0.30"
reqwest = { version = "0.13", default-features = false, features = [
"json",
"query",
"rustls",
"webpki-roots",
"http2",
"hickory-dns",
"stream",
] }
rodio = { version = "0.22", default-features = false, features = [
"playback",
"symphonia-all",
] }
rustypipe = { version = "0.11", default-features = false, features = [
"rustls-tls-webpki-roots",
"userdata",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
# Opus decoding: symphonia has no Opus decoder, so we demux Ogg-Opus with
# symphonia's Ogg reader and decode via the libopus adapter (matches
# rodio's symphonia 0.5). default-features = false drops the adapter's own
# `bundled`, so audio-player's `opus-bundled` decides whether libopus is
# compiled from source (cmake) or linked from the system.
symphonia = { version = "0.5", default-features = false, features = ["ogg"] }
symphonia-adapter-libopus = { version = "0.2", default-features = false }
stream-download = { version = "0.24", default-features = false, features = [
"reqwest",
"reqwest-rustls",
"temp-storage",
] }
tempfile = "3"
thiserror = "2"
tokio = "1"
tokio-stream = "0.1"
toml = "1"
# default-features = false so crabidy-core can select codegen-only for
# wasm builds (a member cannot *drop* workspace-inherited default
# features); native binaries re-enable what they need.
tonic = { version = "0.14", default-features = false }
tonic-prost = "0.14"
tonic-prost-build = "0.14"
tonic-web = "0.14"
tonic-web-wasm-client = "0.9"
tower = "0.5"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
# Local crates. `default-features = false` on the three feature-carrying
# crates: their dependents select what they want (a member cannot *drop* a
# workspace-inherited default), which is how a tailored build stays tailored —
# see architecture/build-features.md D1.
absdy = { path = "absdy" }
audio-player = { path = "audio-player", default-features = false }
cbd-cli = { path = "cbd-cli" }
cbd-tui = { path = "cbd-tui", default-features = false }
crabidy-core = { path = "crabidy-core" }
crabidy-server = { path = "crabidy-server", default-features = false }
fsdy = { path = "fsdy" }
fyyd = { path = "fyyd" }
jamendody = { path = "jamendody" }
rssdy = { path = "rssdy" }
soundclouddy = { path = "soundclouddy" }
tidaldy = { path = "tidaldy" }
ytdy = { path = "ytdy" }

478
README.md
View File

@ -1,478 +0,0 @@
# crabidy
A client/server music player. A headless gRPC server owns the library,
the play queue, and audio output; a terminal UI connects to it over
localhost (or the network). Media comes from pluggable **providers**,
each mounted as a subtree of one library:
```text
/
├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
│ managed by the server
├── abs audiobookshelf audiobooks (absdy/README.md)
├── fs a local music folder (fsdy/README.md)
├── fyyd podcast search (fyyd/README.md)
├── jamendo Creative-Commons music (jamendody/README.md)
├── rss podcast subscriptions (rssdy/README.md)
├── soundcloud SoundCloud (soundclouddy/README.md)
├── tidal Tidal streaming (tidaldy/README.md)
├── youtube YouTube search & playlists (ytdy/README.md)
└── orphans store audio no save references any more — rename, delete,
or queue it
```
Full documentation — every provider, how to log in to each, every config
option, and the architecture — is the **book in [`docs/`](docs/src/)**:
```sh
devenv shell -- docs # serve it locally
```
## Binaries
- `crabidy-server` — the server: providers, queue, playback, gRPC on
`0.0.0.0:50051`. Also serves the web client at that address (see
below).
- `cbd-tui` — the terminal client. Press `?` inside for all key
bindings.
- `cbd` — both in one process: starts the server, waits until it
accepts connections, then runs the TUI. Adopts an already-running
server instead of failing on an occupied port.
- `cbd-web` — the browser client (Leptos/WASM), with the same
functionality as the TUI. Not run directly: it is built to a bundle
and embedded into `crabidy-server` (see
[cbd-web/README.md](cbd-web/README.md)).
## Quick start
The toolchain is managed by [devenv](https://devenv.sh):
```sh
devenv shell # provides rust, yt-dlp, and friends
cargo run -p cbd # server + TUI in one process
```
Or run the halves separately: `cargo run -p crabidy-server` and, in
another terminal, `cargo run -p cbd-tui`.
### Tailored builds
Every provider — plus Opus decoding, the spectrum bars, the embedded web
UI, and the TUI's desktop notifications and MPRIS player — sits behind a
Cargo feature,
all on by default. Drop what you do not need and the dependencies go with
it (`opus-bundled` is the one to drop if you would rather link the system
libopus than compile the vendored copy):
```sh
# a local-files appliance: no network providers, no web UI, no FFT
cargo build --release -p crabidy-server --no-default-features --features fs,opus
```
`crabidy-server features` prints what a binary was built with. See
[docs/src/build-features.md](docs/src/build-features.md) for the full table,
what `fs` takes with it, and more examples.
**Note:** `--no-default-features` on its own drops **every** provider (it
compiles and runs, but plays nothing). Always name what you want.
## Configuration
All configuration lives in `~/.config/crabidy/` (the platform config
directory). Every file is optional; missing providers simply do not
mount. Files are created/rewritten on first start with their defaults
filled in.
| File | Configures | Login |
| --------------------- | ---------------- | ------------------ |
| `abs.toml` | audiobookshelf | URL + API key |
| `fsdy.toml` | local files | — |
| `fyyd.toml` | podcasts | none needed |
| `jamendo.toml` | Jamendo | none (key shipped) |
| `rss.toml` | podcast feeds | the feed URLs |
| `soundcloud.toml` | SoundCloud | optional token |
| `tidaly.toml` | Tidal | device login |
| `ytdy.toml` | YouTube | optional cookies |
| `cbd-tui.toml` | `cbd-tui` | server role + pw |
| `cbd.toml` | `cbd` | server role + pw |
| `crabidy-server.toml` | the server | its `[auth]` hashes|
Each provider's README — linked from the library tree at the top — explains
**how to log in** and documents every option in its file. The same material
is in the book under [`docs/src/providers/`](docs/src/providers/); the client
and server files are covered below.
Provider credentials and the client password are stored in **cleartext**
these are config files, not a keyring. Keep `~/.config/crabidy/` private.
Crabidy redacts secrets from logs, errors, and config dumps.
The server-managed `crabidy` provider does not live under `~/.config`. Its
track-file tree (saved queues, bookmarks, and captures) lives in
`~/.local/state/crabidy/` (the platform *state* directory) and the audio it
captures lives in a single content-addressed store under
`~/.local/share/crabidy/` (the *data* directory), shared and de-duplicated
across saves. Neither needs configuration; see
[docs/src/store.md](docs/src/store.md).
### `cbd-tui.toml` and `cbd.toml`
Client configuration. `cbd-tui` (the standalone terminal client) reads
`cbd-tui.toml`; `cbd` (server + TUI in one process) reads its own
`cbd.toml`. They are **separate files with the same options** so the two
can run side by side on one machine — a common setup is `cbd` playing
locally against its in-process server while `cbd-tui` points at a remote
server (e.g. a Raspberry Pi). A shared file would force one to follow
the other's `address`.
```toml
[server]
# Where to find the server. Default (both files): localhost, which is
# what cbd's own in-process server listens on. Point cbd-tui.toml at a
# remote server to use it as a remote control.
address = "http://127.0.0.1:50051"
# Credentials, when the server has [auth] configured (see below).
# `user` is the role name; leave both empty against an open server.
# The password is stored in plaintext — keep this file private.
user = ""
password = ""
# Show the frequency-spectrum bars under the track progress. Default true.
spectrum = true
# How those bars look. Defaults shown; a color is a "#rrggbb" triple, a
# color name, or a 0-255 palette index, and an unusable value warns and
# falls back. See docs/src/clients/tui.md for what each one does.
spectrum_color = "#bf616a" # the bars (the queue's playing-track red)
spectrum_gradient = true # shade them by height
spectrum_top_color = "#b48ead" # what the shading reaches at the top
spectrum_peak_color = "#81a1c1" # peak-hold shadows; "none" draws none
spectrum_peak_fill = true # false: a thin rule at the peak instead
spectrum_peak_fall = 10.0 # seconds for a full-scale shadow to fall
spectrum_bar_width = 1 # least bar width, in cells
spectrum_bar_gap = 1 # seam dividing two bars, in cells
spectrum_row_gap = 1 # line dividing two rows, in eighths
```
Every option except the `spectrum_*` appearance settings is also available
as a command-line flag before the subcommand (`cbd-tui --address ... --user
owner`, `cbd --spectrum false`); a flag overrides the file value. To write
the credentials into the config once, use the `auth` subcommand (see below)
instead of editing the file by hand:
```sh
cbd-tui auth owner 'my-password' # sets user + password
cbd-tui auth queue-owner 'pw' --address http://pi:50051
```
### `crabidy-server.toml` — providers and rights
On first start the server writes this file with every provider enabled:
```toml
providers = [
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", "rss",
"fs", "crabidy", "orphans",
]
```
**Remove a name to disable that provider** — it no longer mounts and does
not appear in the library. Deleting the whole `providers` line re-enables
everything (a fresh install with no file behaves the same). Disabling
`crabidy` also drops `orphans`, which is a view over the store. A disabled
provider's own config file (`tidaly.toml`, etc.) is simply left unread.
The list can only offer what the binary was **built** with (see [Tailored
builds](#tailored-builds)): the default list names only the providers this
build has, and naming one it lacks logs a warning at startup instead of
failing.
#### Roles and rights
By default the server is open: everyone who can reach the port has
full control. Setting password hashes in `[auth]` locks the server
**from the top down** — each password you set lowers what a
no-credential caller may do, while a matching password elevates a
caller to that role:
- **owner** — everything (the normal user).
- **queue-owner** — anything on the queue and playback, but no
library writes: no bookmarks (`w`), captures (`W`), queue saving,
renames or deletes.
- **queue-appender** — may browse/search and append tracks to the
queue; nothing else.
A caller with no credentials gets the highest role you left *unguarded*:
nothing guarded → owner (the open default); guard `owner` → anonymous is
queue-owner; guard `owner` + `queue_owner` → anonymous is queue-appender;
guard all three → credentials required for everything.
```toml
[auth]
# One PHC hash per role. Guard from the top down. Generate and store a
# hash with: crabidy-server guard <role> (see the CLI section).
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$..."
queue_appender = "$argon2id$..."
```
Clients authenticate with the role name as the basic-auth user (see
`cbd-tui.toml` above). A malformed `crabidy-server.toml` — or one that
guards a lower role while a higher one is still open — aborts server
startup rather than silently running open. Note that the transport is
plain HTTP/2: fine on a trusted home network, but anything exposed
further needs TLS termination (reverse proxy, VPN) in front.
#### Audio output device
By default the server plays to the system default output device. On a
Raspberry Pi that is often HDMI, so playback runs but you hear nothing on
the headphone jack or a USB/DAC. List the devices the server can see:
```console
$ crabidy-server audio-devices
Audio output devices (* = selected by the current config):
hdmi:CARD=vc4hdmi,DEV=0
sysdefault:CARD=Headphones
...
```
Then pin one by passing it to the same command — the value is matched
case-insensitively as a substring of the name, so a memorable fragment is
enough — and restart the server:
```console
$ crabidy-server audio-devices Headphones
Set [audio] device = "Headphones" in .../crabidy-server.toml
```
That writes `[audio] device` for you; you can also edit it by hand:
```toml
[audio]
device = "Headphones"
```
If the name matches nothing, both the command and the server warn, and the
server falls back to the system default.
## Command line
Every binary is a clap CLI: run it with `--help` (and any subcommand
with `--help`) for the full surface. Running a binary with **no
subcommand** behaves as it always has — `crabidy-server` runs the
server, `cbd-tui` runs the TUI, `cbd` runs the in-process server + TUI.
The `library`, `queue`, and `global` subcommands are available on all
three binaries and act as a remote control over gRPC (they connect to a
running server, honouring the same `[auth]` credentials as the TUI):
```sh
crabidy-server library list /tidal # browse a node
cbd-tui --address http://pi:50051 queue append /fs/album
cbd global play # toggle play/pause
cbd global volume -- -0.1 # lower the volume
```
Connection flags (`--address/--user/--password`) go **before** the
subcommand; omitted, they fall back to the client config file.
Server-only subcommands (`crabidy-server`, and `cbd`):
- `guard <role> [password]` — hash a role password (argon2id), print
the PHC string, and (unless `--no-config`) write it into
`crabidy-server.toml`'s `[auth]`. Roles: `owner`, `queue-owner`,
`queue-appender`.
- `scan <path> [--capture|--move]` — walk a folder and drop a
`.cbd-track.toml` beside every audio file so it browses under `/fs`.
`--capture` copies each file into the content store (the toml points
there); `--move` moves it instead of copying.
- `audio-devices [device]` — list the audio output devices, or pin one into
`[audio] device` (see above).
- `features` — print the build features this binary has (see [Tailored
builds](#tailored-builds)).
Client-only subcommand (`cbd-tui`, and `cbd`):
- `auth <role> [password] [--address ADDR]` — write the role name and
cleartext password (and address) into the client config.
**Password caveat.** A password given as a command-line argument is
visible in the process list (e.g. `ps`). Omit it and `guard` reads the
password from stdin instead, which keeps it out of argv and is
pipe-friendly:
```sh
printf '%s' 'my-password' | crabidy-server guard owner
```
The client config stores the password in plaintext, so keep the file
private.
### Completions and man pages
```sh
cbd-tui completions bash # print a completion script
devenv shell -- gen-cli-assets # write dist/completions + dist/man
```
`gen-cli-assets` builds the binaries with `CBD_ASSET_DIR=$PWD/dist`, so
`dist/completions/**` (bash/zsh/fish) and `dist/man/*.1` are produced
for all three binaries. Every ordinary build also emits them into the
crate's `OUT_DIR`.
## Using the library
Navigation is vim-style: `j`/`k` select, `l` enters the selected
folder, `h` goes to the parent, `Tab` switches between library and
queue, `Enter` replaces the queue with the selection. `%` creates a
node where the pane title shows `% to add` (e.g. a search term), `e`
renames, `d` deletes. `/` filters the current pane (library or queue)
live as you type — `Enter` keeps the filter, `Esc` clears it.
`s` marks the selected row; `v` (or `V`) enters **visual mode**, where
movement marks or unmarks everything you sweep over, vim-style. The sweep
is anchored where you entered it, so moving back reverses it. `Esc` (or
any non-movement key) leaves visual mode. Both panes have marks and visual
mode.
In the queue this feeds a vim-style **register**: `y` yanks the selection
into it, `d` deletes the marked rows *into* it, and `c`/`C` fill it with
whatever they clear — so an accidental clear is recoverable. `p` pastes it
after the cursor and `P` before it, which makes `d` then `P` an exact undo
and `d``p` a move. The register is per client, in memory, one slot, and
holds paths — so a paste re-resolves (a yanked album expands to its
tracks). Note `p` no longer inserts the library selection: that flow is now
`y` on the left, then `p` on the right.
- `w` saves the selection (a library subtree, or in the queue pane the
queue) as a new folder under `/crabidy/<name>` of **link** files —
needs the source provider to replay. On a name that already exists the
save is refused with a warning; delete the old folder and save again.
- `W` **captures** the selection into `/crabidy/<name>`: same as `w`, but
every track's audio is fetched into the shared content store under
`~/.local/share/crabidy/` and the saved tomls link to it — fully local
playback afterwards. Works on a library subtree and on the queue (no
need to save it first). Audio is **de-duplicated**: capturing the same
track again (from a playlist, a search, another save) reuses the stored
file instead of downloading it twice, matched first by provider id and
then by content hash. Tracks already local (from `/fs`) are copied into
the store rather than re-downloaded. A source that genuinely cannot be
captured is recorded as *skipped* (red in the UI, skipped by playback).
Download captures can take long; progress is shown in the library pane.
- Captured rows are marked with a trailing `↓` (down-arrow) at the end of
the row — visible even while browsing another provider, so you can see
what you already have.
- Inside `/crabidy`, `d` deletes a folder or track immediately (no
confirmation): it removes only the metadata toml, never the shared store
audio, which other saves may reference.
Press `?` for the full binding table.
A row of frequency-spectrum bars is drawn under the track progress
while audio plays (the server taps its own output, runs the FFT, and
streams the bars, so it works whether the server is local or remote).
Toggle it at runtime with `f`, or set the startup default with
`spectrum = false` in the client config. Servers built without the
`spectrum` feature simply never send bars.
The bars are shaded by height — red at the floor reaching purple at the top
— and divided into segments by a dim seam between bars and a thin line
between value rows. Each bar trails a blue peak-hold shadow marking where it
lately reached, falling away over a few seconds. Every part of that is
configurable, including turning it all off; see
[docs/src/clients/tui.md](docs/src/clients/tui.md).
`,` and `.` seek 15 seconds inside the playing track; `<` and `>` (or
`Ctrl-p`/`Ctrl-n`) skip a whole track. `K`/`J` change the volume and `m`
mutes; the now-playing pane shows the server's level, which tops out at
110%.
## Web client
`crabidy-server` serves a browser client with the same functionality as
the TUI at its own address (`http://<server>:50051/`) — same navigation,
same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, marks and visual mode,
the `y`/`d`/`p` register, `,`/`.` to seek, queue and playback controls, `?`
for help), plus clickable equivalents for all of it: the progress bar seeks
where you click, and `library`/`queue` tabs in the top bar switch panes
without a keyboard. On a phone the two panes cannot sit side by side, so
only the focused one is shown and those tabs are the way between them.
There is a light/dark theme toggle. It talks gRPC-web to the same service
the TUI uses, so it honors the same `[auth]` roles (it shows a login form
when the server requires credentials). The `/` live filter is TUI-only for
now.
It is compiled to a WASM bundle and embedded into the server binary,
behind the default-on `web-ui` cargo feature. A plain `cargo build`
needs no WASM toolchain — it embeds a "not built" placeholder page until
you build the bundle:
```sh
devenv shell -- build-web # writes cbd-web/dist
cargo build -p crabidy-server # embeds it
```
For a headless, gRPC-only binary, build with every feature *except*
`web-ui` — e.g. `--no-default-features --features
all-providers,opus,spectrum`. See [cbd-web/README.md](cbd-web/README.md) for
the dev loop and details.
## Nix packages and cross-compiling
`flake.nix` (built with [crane](https://github.com/ipetkov/crane)) packages
the binaries as Nix derivations — for your own machines, and cross-compiled
for a Raspberry Pi. No Docker required.
Native — install on any machine with Nix:
```sh
nix run .#cbd-tui # run without installing
nix build .#crabidy # cbd, cbd-tui, crabidy-server → ./result/bin
nix profile install .#crabidy # or github:OWNER/crabidy once pushed
```
Raspberry Pi (or any aarch64 Linux) — a fully **static musl** binary, so it
has no glibc-version or loader dependency and runs on stock Raspberry Pi OS
(bookworm and newer):
```sh
nix build .#crabidy-server-aarch64
scp ./result/bin/crabidy-server pi:/usr/local/bin/
```
The native package links the libopus that nixpkgs ships (`opus` without
`opus-bundled`, see
[docs/src/build-features.md](docs/src/build-features.md)), so nothing is
compiled from vendored C and the binaries carry a RUNPATH — they run from a
bare login shell, not only from this repo's dev shell.
Nix cross-compiles the Rust *and* the C dependencies (ALSA, libopus, aws-lc)
hermetically on an x86_64 host — the isolated build avoids the host-linker
pitfalls of cross-compiling in a plain shell. The aarch64 server **includes
the embedded web UI**: the flake builds the `cbd-web` wasm bundle with trunk
(pinning a `wasm-bindgen` CLI that matches the crate) and stages it into the
server's `web-ui` feature. The native `.#crabidy` package stays headless; use
a normal `cargo build` there if you want the bundle.
A container-based `cross` setup also exists (`Cross.toml` + the
`*-Dockerfile`s) for building against Debian's glibc, but the flake is the
recommended path.
## Logs
`cbd` and `cbd-tui` log to `~/.local/state/crabidy/` (daily files);
`crabidy-server` logs to stderr. Stream URLs and credentials are
redacted from logs by design.
## Development
```sh
devenv shell -- bash -lc 'cargo test --workspace --exclude cbd-web'
devenv shell -- check-features # the build-feature matrix
devenv shell -- docs # serve the documentation book
```
`AGENTS.md` (and `CLAUDE.md`) carry the coding rules and the toolchain
conventions. The reference documentation for the system itself lives in
[`docs/`](docs/src/).

View File

@ -1,5 +1,5 @@
FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge
RUN dpkg --add-architecture arm64
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y alsa:arm64 librust-alsa-sys-dev:arm64 libasound2-dev:arm64 portaudio19-dev:arm64 build-essential cmake libpulse-dev:arm64 libdbus-1-dev:arm64 pkg-config apt-utils unzip
RUN apt-get update && apt-get install -y alsa:arm64 librust-alsa-sys-dev:arm64 libasound2-dev:arm64 portaudio19-dev:arm64 build-essential libpulse-dev:arm64 libdbus-1-dev:arm64 pkg-config apt-utils unzip
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip

View File

@ -1,17 +0,0 @@
[package]
name = "absdy"
version.workspace = true
edition.workspace = true
[dependencies]
async-trait.workspace = true
crabidy-core.workspace = true
reqwest.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time"] }
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@ -1,66 +0,0 @@
# absdy — audiobookshelf provider
Mounts a self-hosted [audiobookshelf](https://www.audiobookshelf.org/) (ABS)
server at `/abs`, so you can browse, search, and play your audiobooks from
crabidy. Audiobooks only for now — podcast libraries on the ABS server are
not listed.
## Logging in
The provider needs your server URL and an **API key**; without both, `/abs`
does not mount (the rest of the server is unaffected).
1. Open the audiobookshelf web UI as the user whose libraries you want.
2. **Settings → Users → (your user) → API Keys** (older versions:
**Settings → API Keys**) and create a key.
3. Put it in `abs.toml` (below) and restart the server.
There is no interactive login and no token to refresh — the API key is a
long-lived bearer token. Revoke it in the same screen to cut access.
## Configuration — `~/.config/crabidy/abs.toml`
```toml
# Required.
base_url = "https://audiobookshelf.example.com"
api_key = "<your audiobookshelf API key>"
# Optional (defaults shown).
items_per_library = 200 # books listed per library
search_results = 50 # books listed per search term
call_timeout_secs = 30 # per-request timeout
```
The key is a secret: it is redacted from logs and never printed, but keep
`abs.toml` private anyway (it is stored in cleartext).
## The library tree
```text
/abs
└── <library> one node per "book" library
├── search create a search term with `%`
│ └── <term> books matching the term
│ └── <book> the book's audio files as tracks
└── <book> an audiobook; its files are tracks
└── <file> a track (one audio file)
```
- A **library** lists its books plus a creatable `search` node. Search is
per-library — a term created under one library does not appear under
another.
- A **book** lists its audio files as tracks; queue or capture (`W`) the whole
book, or a single file. An ebook-only item (no audio) is shown but is not
queueable.
- Podcast libraries are not shown (audiobooks only, for now).
## Notes
- Playback streams each file directly from ABS with a per-file token URL and
HTTP range requests — no transcoding session.
- Listings are capped (see the config) and fetched fresh; only your typed
search terms are remembered, in memory, until the server restarts.
- Many audiobookshelf libraries store Opus audio, which needs the server's
`opus` cargo feature (on by default).
- Build the server without the `abs` cargo feature to leave this provider out
of the binary entirely.

View File

@ -1,365 +0,0 @@
//! The audiobookshelf HTTP seam.
//!
//! All network access to an audiobookshelf server goes through the [`Abs`]
//! trait so the provider's tree/path logic is unit-tested with a fake and no
//! network (architecture/audiobookshelf-provider.md D2). [`AbsApi`] is the
//! production `reqwest` implementation; tests supply their own [`Abs`].
//!
//! Every browse call carries `Authorization: Bearer <token>`. The playable
//! stream URL instead embeds the token as a `?token=` query parameter — that
//! is what the audio player fetches directly — and is built by
//! [`Abs::stream_url`] so the **secret never leaves this module**: it is
//! never logged, and it never appears in a `reqwest` error (browse URLs carry
//! no token, and the stream URL is built, not requested, here).
use std::fmt::{self, Debug};
use std::time::Duration;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use thiserror::Error;
use tracing::debug;
/// A typed audiobookshelf request failure. Carries only non-secret context
/// (paths, ids, decode messages) — never the bearer token or a token URL.
#[derive(Debug, Error)]
pub enum FetchError {
/// The HTTP call failed (transport, timeout, or non-success status).
#[error("audiobookshelf request failed: {0}")]
Http(String),
/// The response body did not decode into the expected shape.
#[error("audiobookshelf returned malformed data: {0}")]
Decode(String),
/// The resource does not exist (HTTP 404).
#[error("audiobookshelf resource not found")]
NotFound,
/// Authentication was rejected (HTTP 401/403) — a bad or expired key.
#[error("audiobookshelf authentication failed")]
Unauthorized,
}
/// A library on the server. `is_book` is `true` for `mediaType == "book"` —
/// the only kind this provider serves (podcast libraries are out of scope,
/// architecture/audiobookshelf-provider.md D6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Library {
pub id: String,
pub name: String,
pub is_book: bool,
}
/// A book item as listed in a library or a search result. `num_audio_files`
/// drives queueability without opening the item: an ebook-only item reports
/// `0` and is shown but not queueable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Book {
pub id: String,
pub title: String,
pub author: String,
pub num_audio_files: u32,
}
/// One audio file of a book — a playable track. `ino` is the server's stable
/// file id (an integer string, already URL-safe, used as a path segment and
/// in the stream URL). `duration` is in seconds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioTrack {
pub ino: String,
pub title: String,
pub duration: Option<u32>,
}
/// A book's detail: its metadata plus its audio files in playback order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BookDetail {
pub title: String,
pub author: String,
pub tracks: Vec<AudioTrack>,
}
/// The audiobookshelf operations the provider needs. Behind `Box<dyn Abs>` so
/// tests fake it (architecture/audiobookshelf-provider.md D2).
#[async_trait]
pub trait Abs: Debug + Send + Sync {
/// All libraries on the server.
async fn libraries(&self) -> Result<Vec<Library>, FetchError>;
/// A library's book items (at most `limit`), title-sorted.
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError>;
/// Books in a library matching a free-text term (at most `limit`).
async fn search_items(
&self,
library_id: &str,
term: &str,
limit: usize,
) -> Result<Vec<Book>, FetchError>;
/// One book's detail, including its audio files as ordered tracks.
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError>;
/// The token-authenticated stream URL for a file. Pure string building
/// (no I/O), but on the seam because only the backend holds the base URL
/// and the secret token. The returned URL contains the token and must
/// never be logged.
fn stream_url(&self, item_id: &str, ino: &str) -> String;
}
/// Production `reqwest` client for an audiobookshelf server. `Debug` redacts
/// the token (hard rule: redact secrets from logs and error reports).
pub struct AbsApi {
http: reqwest::Client,
/// Base URL without a trailing slash (e.g. `https://host`).
base_url: String,
/// The API key / bearer token. Secret — redacted from `Debug`, never
/// logged.
token: String,
}
impl Debug for AbsApi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AbsApi")
.field("base_url", &self.base_url)
.field("token", &"<redacted>")
.finish()
}
}
impl AbsApi {
/// Builds the client with a per-call `timeout` (hard rule: timeouts on
/// external calls). `base_url` is the server root (any trailing slash is
/// trimmed); `token` is the API key.
pub fn new(base_url: String, token: String, timeout: Duration) -> Result<Self, FetchError> {
let http = reqwest::Client::builder()
.timeout(timeout)
.user_agent(concat!("crabidy-absdy/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|err| FetchError::Http(err.to_string()))?;
Ok(Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
token,
})
}
/// GETs `path` with `query` under bearer auth and decodes the JSON body.
/// The logged URL carries no token (auth is a header); only
/// [`Abs::stream_url`] embeds the secret.
async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, FetchError> {
let url = format!("{}{}", self.base_url, path);
debug!(url, "abs GET");
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.query(query)
.send()
.await
.map_err(|err| FetchError::Http(err.to_string()))?;
match resp.status() {
reqwest::StatusCode::NOT_FOUND => return Err(FetchError::NotFound),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
return Err(FetchError::Unauthorized)
}
_ => {}
}
let resp = resp
.error_for_status()
.map_err(|err| FetchError::Http(err.to_string()))?;
resp.json()
.await
.map_err(|err| FetchError::Decode(err.to_string()))
}
}
#[async_trait]
impl Abs for AbsApi {
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
let dto: LibrariesDto = self.get("/api/libraries", &[]).await?;
Ok(dto
.libraries
.into_iter()
.filter_map(LibraryDto::into_library)
.collect())
}
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError> {
let count = limit.to_string();
let dto: ItemsDto = self
.get(
&format!("/api/libraries/{library_id}/items"),
&[("limit", &count), ("sort", "media.metadata.title")],
)
.await?;
Ok(dto
.results
.into_iter()
.filter_map(ItemDto::into_book)
.collect())
}
async fn search_items(
&self,
library_id: &str,
term: &str,
limit: usize,
) -> Result<Vec<Book>, FetchError> {
let count = limit.to_string();
let dto: SearchDto = self
.get(
&format!("/api/libraries/{library_id}/search"),
&[("q", term), ("limit", &count)],
)
.await?;
Ok(dto
.book
.into_iter()
.filter_map(|hit| hit.library_item.into_book())
.collect())
}
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError> {
let dto: ItemDto = self
.get(&format!("/api/items/{item_id}"), &[("expanded", "1")])
.await?;
Ok(dto.into_detail())
}
fn stream_url(&self, item_id: &str, ino: &str) -> String {
// The token is appended last and never logged (D4). ABS serves the
// raw file here with HTTP range support, so the audio player streams
// it directly.
format!(
"{}/api/items/{item_id}/file/{ino}?token={}",
self.base_url, self.token
)
}
}
// --- Wire DTOs: decode defensively, missing fields degrade, never panic. ---
#[derive(Debug, Default, Deserialize)]
struct LibrariesDto {
#[serde(default)]
libraries: Vec<LibraryDto>,
}
#[derive(Debug, Default, Deserialize)]
struct LibraryDto {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default, rename = "mediaType")]
media_type: String,
}
impl LibraryDto {
/// Drops entries without an id.
fn into_library(self) -> Option<Library> {
(!self.id.is_empty()).then(|| Library {
is_book: self.media_type == "book",
id: self.id,
name: self.name,
})
}
}
#[derive(Debug, Default, Deserialize)]
struct ItemsDto {
#[serde(default)]
results: Vec<ItemDto>,
}
#[derive(Debug, Default, Deserialize)]
struct ItemDto {
#[serde(default)]
id: String,
#[serde(default)]
media: MediaDto,
}
#[derive(Debug, Default, Deserialize)]
struct MediaDto {
#[serde(default)]
metadata: MetadataDto,
#[serde(default, rename = "numAudioFiles")]
num_audio_files: i64,
#[serde(default)]
tracks: Vec<TrackDto>,
}
#[derive(Debug, Default, Deserialize)]
struct MetadataDto {
#[serde(default)]
title: String,
#[serde(default, rename = "authorName")]
author_name: String,
}
#[derive(Debug, Default, Deserialize)]
struct TrackDto {
#[serde(default)]
ino: String,
#[serde(default)]
title: String,
#[serde(default)]
duration: f64,
}
impl ItemDto {
/// A listed book, dropping entries without an id. `num_audio_files`
/// clamps negatives to 0.
fn into_book(self) -> Option<Book> {
(!self.id.is_empty()).then(|| Book {
title: self.media.metadata.title,
author: self.media.metadata.author_name,
num_audio_files: u32::try_from(self.media.num_audio_files).unwrap_or(0),
id: self.id,
})
}
/// The book's detail with its audio files as ordered tracks (files
/// without an ino are dropped — they cannot be addressed or streamed).
fn into_detail(self) -> BookDetail {
BookDetail {
title: self.media.metadata.title,
author: self.media.metadata.author_name,
tracks: self
.media
.tracks
.into_iter()
.filter_map(TrackDto::into_track)
.collect(),
}
}
}
impl TrackDto {
/// A domain track, dropping files without an ino. A non-positive
/// duration degrades to `None`.
fn into_track(self) -> Option<AudioTrack> {
(!self.ino.is_empty()).then(|| AudioTrack {
ino: self.ino,
title: self.title,
duration: (self.duration > 0.0).then_some(self.duration.round() as u32),
})
}
}
#[derive(Debug, Default, Deserialize)]
struct SearchDto {
#[serde(default)]
book: Vec<SearchHit>,
}
#[derive(Debug, Deserialize)]
struct SearchHit {
#[serde(rename = "libraryItem")]
library_item: ItemDto,
}

View File

@ -1,651 +0,0 @@
//! audiobookshelf provider: **browse, search, and play** the audiobooks on a
//! self-hosted [audiobookshelf](https://www.audiobookshelf.org/) server.
//! Mounted at [`PROVIDER_ROOT`].
//!
//! Shaped on the fyyd provider. The tree is `library → book → tracks`, where a
//! book's tracks are its audio files, plus a per-library `search` subtree with
//! creatable/renamable/deletable search-term nodes like tidal/youtube/fyyd. A
//! track is one audio file whose `?token=` stream URL the audio player fetches
//! directly; every node that serves tracks is downloadable, so `W` captures
//! work out of the box.
//!
//! audiobookshelf is the user's private server, so — unlike fyyd — a usable
//! `abs.toml` must carry a `base_url` and an `api_key`; a missing or
//! incomplete config disables the provider non-fatally. The `api_key` and the
//! token-bearing stream URL are secrets, redacted from `Debug` and never
//! logged.
//!
//! See architecture/audiobookshelf-provider.md for the tree shape and
//! decisions.
use std::collections::HashMap;
use std::fmt;
use std::sync::RwLock;
use std::time::Duration;
use async_trait::async_trait;
use crabidy_core::proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track};
use crabidy_core::{ProviderClient, ProviderError};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
pub mod api;
use api::{Abs, AbsApi, Book, BookDetail};
/// First path segment owned by this provider.
pub const PROVIDER_ROOT: &str = "/abs";
/// Reserved second-level segment separating the search subtree from item ids
/// (item ids are UUIDs, so they never collide with this literal).
const SEARCH_SEGMENT: &str = "search";
/// Default (and cap on) items listed under a library — a huge library must
/// not stall the tree or queue resolution.
pub const DEFAULT_ITEMS_PER_LIBRARY: usize = 200;
/// Default number of books listed per search term.
pub const DEFAULT_SEARCH_RESULTS: usize = 50;
/// Default per-request timeout in seconds.
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
/// Provider settings, persisted as `abs.toml`. `base_url` and `api_key` are
/// required for the provider to run; the rest have defaults.
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Settings {
/// The audiobookshelf server root, e.g. `https://abs.example.com`.
/// Required — without it the provider is disabled.
pub base_url: Option<String>,
/// The API key (bearer token). Required — without it the provider is
/// disabled. **Secret**: redacted from `Debug`, never logged.
pub api_key: Option<String>,
/// Items listed per library. Default [`DEFAULT_ITEMS_PER_LIBRARY`].
pub items_per_library: Option<usize>,
/// Books listed per search term. Default [`DEFAULT_SEARCH_RESULTS`].
pub search_results: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
}
impl fmt::Debug for Settings {
/// Redacts `api_key` (hard rule: secrets never reach logs or reports).
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Settings")
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("items_per_library", &self.items_per_library)
.field("search_results", &self.search_results)
.field("call_timeout_secs", &self.call_timeout_secs)
.finish()
}
}
/// A parsed `/abs/...` path. The direct-browse and search branches share the
/// same book/track shapes below a library.
#[derive(Debug, PartialEq, Eq)]
enum AbsPath<'a> {
Root,
Library(&'a str),
LibraryBook {
library: &'a str,
item: &'a str,
},
LibraryTrack {
library: &'a str,
item: &'a str,
ino: &'a str,
},
Search(&'a str),
/// Percent-encoded search-term segment.
SearchTerm {
library: &'a str,
term: &'a str,
},
SearchBook {
library: &'a str,
term: &'a str,
item: &'a str,
},
SearchTrack {
library: &'a str,
term: &'a str,
item: &'a str,
ino: &'a str,
},
}
/// Splits an `/abs/...` path into its recognized shape. Unknown shapes are
/// [`ProviderError::MalformedPath`].
fn parse_path(path: &str) -> Result<AbsPath<'_>, ProviderError> {
if path == PROVIDER_ROOT {
return Ok(AbsPath::Root);
}
let rest = path
.strip_prefix("/abs/")
.ok_or(ProviderError::MalformedPath)?;
let segments: Vec<&str> = rest.split('/').collect();
if segments.iter().any(|segment| segment.is_empty()) {
return Err(ProviderError::MalformedPath);
}
// Literal-`search` arms first so a `search` second segment routes to the
// search branch; item ids (UUIDs) never equal `search`.
match segments.as_slice() {
[lib, s] if *s == SEARCH_SEGMENT => Ok(AbsPath::Search(lib)),
[lib, s, term] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTerm { library: lib, term }),
[lib, s, term, item] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchBook {
library: lib,
term,
item,
}),
[lib, s, term, item, ino] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTrack {
library: lib,
term,
item,
ino,
}),
[lib] => Ok(AbsPath::Library(lib)),
[lib, item] => Ok(AbsPath::LibraryBook { library: lib, item }),
[lib, item, ino] => Ok(AbsPath::LibraryTrack {
library: lib,
item,
ino,
}),
_ => Err(ProviderError::MalformedPath),
}
}
/// Maps a fetch failure to the trait-level error, logging the typed cause.
fn fetch_err(context: &str, err: api::FetchError) -> ProviderError {
warn!(context, "abs fetch failed: {err}");
ProviderError::FetchError
}
/// The audiobookshelf provider client.
pub struct Client {
api: Box<dyn Abs>,
settings: Settings,
/// Search terms created under `/abs/<lib>/search`, keyed by library id, in
/// creation order, deduplicated. In-memory only. Never held across awaits.
search_terms: RwLock<HashMap<String, Vec<String>>>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("api", &self.api)
.field("settings", &self.settings)
.finish_non_exhaustive()
}
}
impl Client {
/// A client over any [`Abs`] backend — the seam the tests use.
fn with_api(api: Box<dyn Abs>, settings: Settings) -> Self {
Self {
api,
settings,
search_terms: RwLock::new(HashMap::new()),
}
}
fn items_limit(&self) -> usize {
self.settings
.items_per_library
.unwrap_or(DEFAULT_ITEMS_PER_LIBRARY)
}
fn search_results_limit(&self) -> usize {
self.settings
.search_results
.unwrap_or(DEFAULT_SEARCH_RESULTS)
}
/// A node listing `books` as children. A book is queueable only when it
/// has audio files (an ebook-only item reports zero and is shown but not
/// queueable). Used for both a library browse and a search-term node.
fn book_listing_node(
&self,
path: &str,
title: String,
parent: String,
books: &[Book],
extra_children: Vec<LibraryNodeChild>,
) -> LibraryNode {
let mut children = extra_children;
children.extend(books.iter().map(|book| LibraryNodeChild {
..LibraryNodeChild::new(
crabidy_core::join_path(path, &book.id),
book.title.clone(),
book.num_audio_files > 0,
)
}));
LibraryNode {
path: path.to_string(),
title,
parent: Some(parent),
tracks: Vec::new(),
children,
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// One book's audio files as tracks. Queueable and downloadable —
/// homogeneous tracks. An audio-less item (ebook) yields an empty,
/// non-queueable node rather than an error.
async fn book_node(
&self,
path: &str,
item_id: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
let detail = self
.api
.item_detail(item_id)
.await
.map_err(|err| fetch_err("item detail", err))?;
let tracks: Vec<Track> = detail
.tracks
.iter()
.map(|track| book_track(path, item_id, &detail, track))
.collect();
let is_queable = !tracks.is_empty();
Ok(LibraryNode {
path: path.to_string(),
title: detail.title,
parent: Some(parent),
tracks,
children: Vec::new(),
is_queable,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
})
}
/// A library node: its books as children, prefixed by the creatable
/// `search` child.
async fn library_node(
&self,
path: &str,
library_id: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
let books = self
.api
.library_items(library_id, self.items_limit())
.await
.map_err(|err| fetch_err("library items", err))?;
if books.len() >= self.items_limit() {
debug!(
library_id,
limit = self.items_limit(),
"library listing truncated to the configured cap"
);
}
let search_child = LibraryNodeChild {
is_creatable: true,
..LibraryNodeChild::new(
crabidy_core::join_path(path, SEARCH_SEGMENT),
SEARCH_SEGMENT.to_string(),
false,
)
};
Ok(self.book_listing_node(
path,
library_id.to_string(),
parent,
&books,
vec![search_child],
))
}
/// A search-term node: books matching the term as children.
async fn search_term_node(
&self,
path: &str,
library_id: &str,
term: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
let books = self
.api
.search_items(library_id, term, self.search_results_limit())
.await
.map_err(|err| fetch_err("search", err))?;
Ok(self.book_listing_node(path, term.to_string(), parent, &books, Vec::new()))
}
/// The `/abs/<lib>/search` node listing the library's search terms.
fn search_node(&self, path: &str, library_id: &str, parent: String) -> LibraryNode {
LibraryNode {
path: path.to_string(),
title: SEARCH_SEGMENT.to_string(),
parent: Some(parent),
tracks: Vec::new(),
children: self
.search_terms_snapshot(library_id)
.iter()
.map(|term| LibraryNodeChild {
// Term nodes are the modifiable nodes: renamable (`e`) and
// deletable (`d`), like tidal/youtube/fyyd.
is_editable: true,
is_deletable: true,
..LibraryNodeChild::new(
crabidy_core::join_path(path, &crabidy_core::encode_segment(term)),
term.clone(),
false,
)
})
.collect(),
is_queable: false,
is_creatable: true,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
fn search_terms_snapshot(&self, library_id: &str) -> Vec<String> {
self.search_terms
.read()
.ok()
.and_then(|terms| terms.get(library_id).cloned())
.unwrap_or_default()
}
fn register_search_term(&self, library_id: &str, term: &str) {
if let Ok(mut terms) = self.search_terms.write() {
let list = terms.entry(library_id.to_string()).or_default();
if !list.iter().any(|existing| existing == term) {
list.push(term.to_string());
}
}
}
/// Removes a term; `true` when it existed.
fn remove_search_term(&self, library_id: &str, term: &str) -> bool {
match self.search_terms.write() {
Ok(mut terms) => {
let Some(list) = terms.get_mut(library_id) else {
return false;
};
let before = list.len();
list.retain(|existing| existing != term);
list.len() != before
}
Err(_) => false,
}
}
/// The `(item, ino)` addressed by a track path, or `MalformedPath`.
fn track_item_ino<'a>(&self, path: &'a str) -> Result<(&'a str, &'a str), ProviderError> {
match parse_path(path)? {
AbsPath::LibraryTrack { item, ino, .. } | AbsPath::SearchTrack { item, ino, .. } => {
Ok((item, ino))
}
_ => Err(ProviderError::MalformedPath),
}
}
}
/// Builds the wire track for one audio file. `artist` is the book's author,
/// `album` the book title; missing fields degrade to empty/`None`.
fn book_track(
node_path: &str,
item_id: &str,
detail: &BookDetail,
track: &api::AudioTrack,
) -> Track {
Track {
path: crabidy_core::join_path(node_path, &track.ino),
artist: detail.author.clone(),
title: track.title.clone(),
duration: track.duration,
album: (!detail.title.is_empty()).then(|| Album {
title: detail.title.clone(),
release_date: None,
}),
is_skipped: false,
provider_item_id: format!("{item_id}:{}", track.ino),
is_captured: false,
}
}
#[async_trait]
impl ProviderClient for Client {
/// Builds the `reqwest`-backed client. A missing `base_url`/`api_key`, or
/// a client that cannot be built, fails init — the orchestrator then
/// disables the provider non-fatally
/// (architecture/audiobookshelf-provider.md D1).
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
warn!("could not parse abs.toml, using defaults");
Settings::default()
});
let base_url = settings
.base_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
warn!("abs provider disabled: no base_url in abs.toml");
ProviderError::Config("audiobookshelf base_url is required".to_string())
})?
.to_string();
let token = settings
.api_key
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
warn!("abs provider disabled: no api_key in abs.toml");
ProviderError::Config("audiobookshelf api_key is required".to_string())
})?
.to_string();
let timeout = Duration::from_secs(
settings
.call_timeout_secs
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
);
let api = AbsApi::new(base_url, token, timeout).map_err(|err| {
warn!("cannot build the abs client: {err}");
ProviderError::Config(err.to_string())
})?;
debug!("abs provider ready");
Ok(Self::with_api(Box::new(api), settings))
}
fn settings(&self) -> String {
toml::to_string_pretty(&self.settings).unwrap_or_default()
}
fn is_track_path(&self, path: &str) -> bool {
matches!(
parse_path(path),
Ok(AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. })
)
}
/// The file's `?token=` stream URL — built from the path, no API call.
/// The token is embedded and must never be logged (D4).
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
let (item, ino) = self.track_item_ino(track_path)?;
Ok(vec![self.api.stream_url(item, ino)])
}
/// Single-track metadata: fetch the book detail and pick the file by ino.
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
let (item, ino) = self.track_item_ino(track_path)?;
let detail = self
.api
.item_detail(item)
.await
.map_err(|err| fetch_err("track metadata", err))?;
let track = detail
.tracks
.iter()
.find(|track| track.ino == ino)
.ok_or_else(|| {
warn!(track_path, "abs item has no file with this ino");
ProviderError::FetchError
})?;
let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT);
let mut wire = book_track(parent, item, &detail, track);
// The caller's path is canonical.
wire.path = track_path.to_string();
Ok(wire)
}
/// A placeholder — the real `/abs` root lists libraries, which needs a
/// network call, so it is served by [`Self::get_lib_node`] (the sync trait
/// method cannot await). The orchestrator only builds the global-root link
/// from [`PROVIDER_ROOT`], never from these children.
fn get_lib_root(&self) -> LibraryNode {
LibraryNode {
path: PROVIDER_ROOT.to_string(),
title: "abs".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
children: Vec::new(),
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let parent = crabidy_core::parent_path(path)
.unwrap_or(crabidy_core::ROOT_PATH)
.to_string();
let node = match parse_path(path)? {
AbsPath::Root => {
// The real root: book libraries as children (podcast
// libraries are out of scope, D6).
let libraries = self
.api
.libraries()
.await
.map_err(|err| fetch_err("libraries", err))?;
LibraryNode {
children: libraries
.iter()
.filter(|library| library.is_book)
.map(|library| {
LibraryNodeChild::new(
crabidy_core::join_path(PROVIDER_ROOT, &library.id),
library.name.clone(),
false,
)
})
.collect(),
..self.get_lib_root()
}
}
AbsPath::Library(library) => self.library_node(path, library, parent).await?,
AbsPath::Search(library) => self.search_node(path, library, parent),
AbsPath::SearchTerm { library, term } => {
let decoded = crabidy_core::decode_segment(term);
// Unknown terms (stale client cache, server restart) are
// recreated implicitly instead of erroring.
self.register_search_term(library, &decoded);
self.search_term_node(path, library, &decoded, parent)
.await?
}
AbsPath::LibraryBook { item, .. } | AbsPath::SearchBook { item, .. } => {
self.book_node(path, item, parent).await?
}
AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. } => {
warn!(path, "get_lib_node called with a track path");
return Err(ProviderError::MalformedPath);
}
};
// The central download blessing (same rule as fyyd/youtube/tidal):
// every node serving playable content allows `W`; children mirror
// their queueability (a book child is queueable, so a whole book is
// capturable). The `search` child stays non-queueable, so it is not
// blessed.
let mut node = node;
node.is_downloadable = node.is_queable || !node.tracks.is_empty();
for child in &mut node.children {
child.is_downloadable = child.is_queable;
}
Ok(node)
}
/// Only `/abs/<lib>/search` is creatable: registers the term and returns
/// its node (implicit recreation on stale paths, like tidal/youtube/fyyd).
async fn create_lib_node(
&self,
parent_path: &str,
title: &str,
) -> Result<LibraryNode, ProviderError> {
let term = title.trim();
if term.is_empty() {
return Err(ProviderError::InvalidInput);
}
let AbsPath::Search(library) = parse_path(parent_path)? else {
warn!(parent_path, "node creation not supported here");
return Err(ProviderError::NotSupported);
};
self.register_search_term(library, term);
let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term));
self.get_lib_node(&term_path).await
}
/// Renaming a search term re-runs the search under the new term.
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<LibraryNode, ProviderError> {
let AbsPath::SearchTerm { library, term } = parse_path(path)? else {
warn!(path, "only search terms are renamable");
return Err(ProviderError::NotSupported);
};
let new_term = new_title.trim();
if new_term.is_empty() {
return Err(ProviderError::InvalidInput);
}
let old_term = crabidy_core::decode_segment(term);
// Replace in place; renaming onto an existing term merges (the
// duplicate disappears), like tidal's/fyyd's terms.
self.remove_search_term(library, &old_term);
self.register_search_term(library, new_term);
let new_path = crabidy_core::join_path(
&crabidy_core::join_path(
&crabidy_core::join_path(PROVIDER_ROOT, library),
SEARCH_SEGMENT,
),
&crabidy_core::encode_segment(new_term),
);
self.get_lib_node(&new_path).await
}
/// Deleting a search term is idempotent and returns the refreshed search
/// node.
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let AbsPath::SearchTerm { library, term } = parse_path(path)? else {
warn!(path, "only search terms are deletable");
return Err(ProviderError::NotSupported);
};
let decoded = crabidy_core::decode_segment(term);
self.remove_search_term(library, &decoded);
let search_path = crabidy_core::join_path(
&crabidy_core::join_path(PROVIDER_ROOT, library),
SEARCH_SEGMENT,
);
self.get_lib_node(&search_path).await
}
}
#[cfg(test)]
mod tests {
include!("tests.rs");
}

View File

@ -1,380 +0,0 @@
// Included from lib.rs `mod tests`. Provider logic (tree shaping, path
// parsing, term store, stream-URL building, secret redaction) is exercised
// against a programmable [`Abs`] backend with zero network
// (architecture/audiobookshelf-provider.md D2, quality/audiobookshelf-provider.md).
use super::*;
use api::{AudioTrack, Book, BookDetail, FetchError, Library};
/// A programmable audiobookshelf backend.
#[derive(Debug, Default)]
struct FakeApi {
libraries: Vec<Library>,
/// library id → books.
items: HashMap<String, Vec<Book>>,
/// (library id, term) → books.
searches: HashMap<(String, String), Vec<Book>>,
/// item id → detail.
details: HashMap<String, BookDetail>,
base_url: String,
token: String,
}
#[async_trait]
impl Abs for FakeApi {
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
Ok(self.libraries.clone())
}
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError> {
self.items
.get(library_id)
.map(|books| books.iter().take(limit).cloned().collect())
.ok_or(FetchError::NotFound)
}
async fn search_items(
&self,
library_id: &str,
term: &str,
limit: usize,
) -> Result<Vec<Book>, FetchError> {
self.searches
.get(&(library_id.to_string(), term.to_string()))
.map(|books| books.iter().take(limit).cloned().collect())
.ok_or_else(|| FetchError::Http(format!("no search fixture for {term:?}")))
}
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError> {
self.details.get(item_id).cloned().ok_or(FetchError::NotFound)
}
fn stream_url(&self, item_id: &str, ino: &str) -> String {
format!(
"{}/api/items/{item_id}/file/{ino}?token={}",
self.base_url, self.token
)
}
}
fn book(id: &str, title: &str, author: &str, num_audio_files: u32) -> Book {
Book {
id: id.to_string(),
title: title.to_string(),
author: author.to_string(),
num_audio_files,
}
}
fn track(ino: &str, title: &str, duration: Option<u32>) -> AudioTrack {
AudioTrack {
ino: ino.to_string(),
title: title.to_string(),
duration,
}
}
/// The standard fixture: two book libraries and one podcast library; `lib1`
/// has an audiobook (`b1`, two files) and an ebook (`e1`, no audio); a search
/// for `neuro` in `lib1` matches `b1`.
fn fake() -> FakeApi {
FakeApi {
libraries: vec![
Library {
id: "lib1".into(),
name: "Audiobook".into(),
is_book: true,
},
Library {
id: "lib2".into(),
name: "Manning".into(),
is_book: true,
},
Library {
id: "pod1".into(),
name: "Podcasts".into(),
is_book: false,
},
],
items: HashMap::from([(
"lib1".to_string(),
vec![
book("b1", "Neuromancer", "William Gibson", 2),
book("e1", "An Ebook", "Nobody", 0),
],
)]),
searches: HashMap::from([(
("lib1".to_string(), "neuro".to_string()),
vec![book("b1", "Neuromancer", "William Gibson", 2)],
)]),
details: HashMap::from([
(
"b1".to_string(),
BookDetail {
title: "Neuromancer".into(),
author: "William Gibson".into(),
tracks: vec![
track("111", "part1.opus", Some(60)),
track("222", "part2.opus", None),
],
},
),
(
"e1".to_string(),
BookDetail {
title: "An Ebook".into(),
author: "Nobody".into(),
tracks: vec![],
},
),
]),
base_url: "https://abs.test".into(),
token: "SECRET".into(),
}
}
fn client_with(api: FakeApi) -> Client {
Client::with_api(Box::new(api), Settings::default())
}
fn client() -> Client {
client_with(fake())
}
#[tokio::test]
async fn root_lists_only_book_libraries() {
let node = client().get_lib_node("/abs").await.expect("root");
let names: Vec<&str> = node.children.iter().map(|c| c.title.as_str()).collect();
assert_eq!(names, vec!["Audiobook", "Manning"], "podcast library filtered");
assert_eq!(node.children[0].path, "/abs/lib1");
assert!(!node.children[0].is_queable, "a library is a container");
}
#[tokio::test]
async fn a_library_lists_search_then_books_with_audio_gating_queueability() {
let node = client().get_lib_node("/abs/lib1").await.expect("library");
assert!(!node.is_creatable && !node.is_queable);
// search first, then the books.
let first = &node.children[0];
assert_eq!(first.title, "search");
assert_eq!(first.path, "/abs/lib1/search");
assert!(first.is_creatable && !first.is_queable && !first.is_downloadable);
let audiobook = &node.children[1];
assert_eq!(audiobook.path, "/abs/lib1/b1");
assert_eq!(audiobook.title, "Neuromancer");
assert!(
audiobook.is_queable && audiobook.is_downloadable,
"a book with audio queues/captures whole"
);
let ebook = &node.children[2];
assert_eq!(ebook.title, "An Ebook");
assert!(
!ebook.is_queable && !ebook.is_downloadable,
"an ebook (no audio) is shown but not queueable"
);
}
#[tokio::test]
async fn a_book_lists_its_files_as_tracks() {
let node = client().get_lib_node("/abs/lib1/b1").await.expect("book");
assert_eq!(node.title, "Neuromancer");
assert!(node.is_queable && node.is_downloadable);
assert_eq!(node.tracks.len(), 2);
let one = &node.tracks[0];
assert_eq!(one.path, "/abs/lib1/b1/111");
assert_eq!(one.title, "part1.opus");
assert_eq!(one.artist, "William Gibson", "artist is the author");
assert_eq!(
one.album.as_ref().map(|a| a.title.as_str()),
Some("Neuromancer"),
"album is the book"
);
assert_eq!(one.duration, Some(60));
assert_eq!(one.provider_item_id, "b1:111");
// Missing duration degrades to None, never an error.
assert_eq!(node.tracks[1].duration, None);
}
#[tokio::test]
async fn an_audio_less_book_is_empty_and_not_queueable() {
let node = client().get_lib_node("/abs/lib1/e1").await.expect("ebook");
assert!(node.tracks.is_empty());
assert!(!node.is_queable && !node.is_downloadable);
}
#[tokio::test]
async fn search_terms_list_books_and_are_per_library() {
let client = client();
let node = client
.create_lib_node("/abs/lib1/search", "neuro")
.await
.expect("create term");
assert_eq!(node.path, "/abs/lib1/search/neuro");
assert!(!node.is_queable && node.tracks.is_empty());
assert_eq!(node.children.len(), 1);
let hit = &node.children[0];
assert_eq!(hit.path, "/abs/lib1/search/neuro/b1");
assert_eq!(hit.title, "Neuromancer");
assert!(hit.is_queable && hit.is_downloadable);
// The term shows under this library's search node, editable/deletable ...
let search = client.get_lib_node("/abs/lib1/search").await.expect("search");
assert!(search.is_creatable);
assert_eq!(search.children.len(), 1);
assert!(search.children[0].is_editable && search.children[0].is_deletable);
// ... but not under another library's search node (per-library store).
let other = client.get_lib_node("/abs/lib2/search").await.expect("search");
assert!(other.children.is_empty(), "terms do not leak across libraries");
}
#[tokio::test]
async fn a_book_under_a_search_term_lists_the_same_tracks() {
let client = client();
client
.create_lib_node("/abs/lib1/search", "neuro")
.await
.expect("create term");
let node = client
.get_lib_node("/abs/lib1/search/neuro/b1")
.await
.expect("book via search");
assert_eq!(node.tracks.len(), 2);
assert_eq!(node.tracks[0].path, "/abs/lib1/search/neuro/b1/111");
assert_eq!(node.tracks[0].provider_item_id, "b1:111");
}
#[tokio::test]
async fn search_terms_rename_and_delete() {
let client = client();
client
.create_lib_node("/abs/lib1/search", "neuro")
.await
.expect("create");
let renamed = client
.rename_lib_node("/abs/lib1/search/neuro", "neuro")
.await
.expect("rename re-searches");
assert_eq!(renamed.path, "/abs/lib1/search/neuro");
assert_eq!(client.search_terms_snapshot("lib1"), vec!["neuro".to_string()]);
let search = client
.delete_lib_node("/abs/lib1/search/neuro")
.await
.expect("delete");
assert!(search.children.is_empty());
// Idempotent.
let again = client
.delete_lib_node("/abs/lib1/search/neuro")
.await
.expect("idempotent delete");
assert!(again.children.is_empty());
// Only search terms are mutable.
assert!(client.rename_lib_node("/abs/lib1", "nope").await.is_err());
assert!(client.delete_lib_node("/abs/lib1/b1").await.is_err());
}
#[tokio::test]
async fn track_stream_url_embeds_the_token_and_path_ids_without_a_call() {
let client = client();
assert!(client.is_track_path("/abs/lib1/b1/111"));
assert!(client.is_track_path("/abs/lib1/search/neuro/b1/111"));
assert!(!client.is_track_path("/abs/lib1/b1"));
assert!(!client.is_track_path("/abs/lib1"));
let urls = client
.get_urls_for_track("/abs/lib1/b1/111")
.await
.expect("stream url");
assert_eq!(
urls,
vec!["https://abs.test/api/items/b1/file/111?token=SECRET".to_string()]
);
// The search branch resolves to the same file URL.
let via_search = client
.get_urls_for_track("/abs/lib1/search/neuro/b1/111")
.await
.expect("stream url");
assert_eq!(via_search, urls);
}
#[tokio::test]
async fn track_metadata_picks_the_file_by_ino() {
let client = client();
let track = client
.get_metadata_for_track("/abs/lib1/b1/222")
.await
.expect("metadata");
assert_eq!(track.title, "part2.opus");
assert_eq!(track.artist, "William Gibson");
assert_eq!(track.path, "/abs/lib1/b1/222");
assert_eq!(track.duration, None);
// A file id that the book does not have is a typed error, not a panic.
assert!(client
.get_metadata_for_track("/abs/lib1/b1/999")
.await
.is_err());
}
#[tokio::test]
async fn backend_failures_are_typed_never_panics() {
let client = client_with(FakeApi::default());
// An empty server (no libraries) is a valid empty root, not an error.
assert!(client.get_lib_node("/abs").await.expect("empty root").children.is_empty());
// But a missing library / item / search is a typed fetch error.
assert!(client.get_lib_node("/abs/lib1").await.is_err());
assert!(client.get_lib_node("/abs/lib1/b1").await.is_err());
client.register_search_term("lib1", "neuro");
assert!(client.get_lib_node("/abs/lib1/search/neuro").await.is_err());
assert!(client.get_metadata_for_track("/abs/lib1/b1/111").await.is_err());
// But building a stream URL never touches the backend, so it still works.
assert!(client.get_urls_for_track("/abs/lib1/b1/111").await.is_ok());
}
#[tokio::test]
async fn foreign_and_malformed_paths_are_rejected() {
let client = client();
for path in [
"/tidal/artists",
"/abs/lib1/b1/111/extra",
"/abs//b1",
"/absnope",
] {
assert!(client.get_lib_node(path).await.is_err(), "{path}");
}
assert!(client.create_lib_node("/abs/lib1", "term").await.is_err());
assert!(client.create_lib_node("/abs/lib1/search", " ").await.is_err());
}
#[test]
fn settings_debug_redacts_the_api_key() {
let settings = Settings {
base_url: Some("https://abs.test".into()),
api_key: Some("super-secret-token".into()),
..Settings::default()
};
let shown = format!("{settings:?}");
assert!(!shown.contains("super-secret-token"), "api_key must be redacted");
assert!(shown.contains("<redacted>"));
assert!(shown.contains("abs.test"), "non-secret fields are shown");
}
#[test]
fn settings_round_trip() {
let settings: Settings = toml::from_str(
"base_url = \"https://abs.test\"\napi_key = \"k\"\nitems_per_library = 10\nsearch_results = 5\ncall_timeout_secs = 10\n",
)
.expect("parses");
assert_eq!(settings.base_url.as_deref(), Some("https://abs.test"));
assert_eq!(settings.items_per_library, Some(10));
assert_eq!(settings.search_results, Some(5));
assert_eq!(settings.call_timeout_secs, Some(10));
}
#[tokio::test]
async fn init_requires_base_url_and_api_key() {
// Empty config disables the provider (non-fatal at the orchestrator).
assert!(Client::init("").await.is_err());
assert!(Client::init("base_url = \"https://abs.test\"").await.is_err());
assert!(Client::init("api_key = \"k\"").await.is_err());
// Both present: the client builds (no network until a call).
assert!(Client::init("base_url = \"https://abs.test\"\napi_key = \"k\"")
.await
.is_ok());
}

View File

@ -1,73 +0,0 @@
//! Live validation against a real audiobookshelf server. `#[ignore]`d so it
//! never runs in CI or hits the network by default; run it deliberately with
//!
//! ```sh
//! ABS_BASE_URL=https://host ABS_API_KEY=<key> \
//! cargo test -p absdy --test live -- --ignored --nocapture
//! ```
//!
//! It exercises the real endpoints end-to-end (libraries → items → search →
//! detail) so the `AbsApi` DTOs are confirmed against the live JSON shapes —
//! the drift risk called out in architecture/audiobookshelf-provider.md.
use std::time::Duration;
use absdy::api::{Abs, AbsApi};
fn creds() -> Option<(String, String)> {
let base = std::env::var("ABS_BASE_URL").ok()?;
let key = std::env::var("ABS_API_KEY").ok()?;
(!base.is_empty() && !key.is_empty()).then_some((base, key))
}
#[tokio::test]
#[ignore = "hits a real audiobookshelf server; set ABS_BASE_URL and ABS_API_KEY"]
async fn live_browse_search_and_detail() {
let Some((base, key)) = creds() else {
eprintln!("ABS_BASE_URL / ABS_API_KEY unset — skipping live test");
return;
};
let api = AbsApi::new(base, key, Duration::from_secs(30)).expect("client");
let libraries = api.libraries().await.expect("libraries decode");
assert!(!libraries.is_empty(), "server has at least one library");
let book_lib = libraries
.iter()
.find(|l| l.is_book)
.expect("at least one book library");
println!("book library: {} ({})", book_lib.name, book_lib.id);
let items = api
.library_items(&book_lib.id, 5)
.await
.expect("items decode");
assert!(!items.is_empty(), "book library has items");
let with_audio = items
.iter()
.find(|b| b.num_audio_files > 0)
.expect("an audiobook item");
println!(
"item: {} by {} ({} files)",
with_audio.title, with_audio.author, with_audio.num_audio_files
);
let detail = api
.item_detail(&with_audio.id)
.await
.expect("detail decode");
assert!(!detail.tracks.is_empty(), "audiobook has tracks");
let first = &detail.tracks[0];
println!("track0: ino={} title={}", first.ino, first.title);
// The stream URL is fully derivable and carries the token.
let url = api.stream_url(&with_audio.id, &first.ino);
assert!(url.contains(&format!("/api/items/{}/file/{}", with_audio.id, first.ino)));
assert!(url.contains("token="));
// Search should decode too (term may legitimately match nothing).
let hits = api
.search_items(&book_lib.id, "a", 3)
.await
.expect("search decode");
println!("search 'a' -> {} hits", hits.len());
}

View File

@ -1,242 +0,0 @@
# audiobookshelf provider (audiobooks)
## Context and problem statement
A new library provider mounted at `/abs` that lets a user **browse, search,
and play** the audiobooks on a self-hosted
[audiobookshelf](https://www.audiobookshelf.org/) (ABS) server.
- Unlike fyyd/tidal/youtube, ABS is **the user's own private server**, reached
over an authenticated HTTP API with an **API key** (a bearer token). So —
like tidal — the provider needs credentials, and — unlike tidal — a missing
or incomplete config disables it non-fatally (it is one optional server, not
the whole app).
- ABS organizes content as **libraries → items → audio files**. An audiobook
*item* is usually split into many audio files (e.g. `Neuromancer-01.opus`
`-30.opus`); each file is one **track**. So the tree carries the same
"one extra level" as fyyd — `library → book → tracks` — plus a per-library
**search** subtree that mirrors tidal/youtube search-term semantics.
- **Playing** a track means streaming the file's content endpoint. ABS accepts
the API key as a `?token=` query parameter on that endpoint and honors HTTP
range requests, so the URL is exactly the shape the audio player already
handles — **and the whole URL is derivable from the path**, so playback
needs no extra API call, no sidecar, no byte-proxying (contrast `/youtube`).
- **Captures** (`W`, download) come for free: any node that serves tracks and
raises `is_downloadable` gets `w`/`W` with no wire or TUI work.
## The audiobookshelf API (grounding — live-validated against the test server)
Base `https://<host>/api/`. Every call carries `Authorization: Bearer <key>`,
**except** the file endpoint which also accepts `?token=<key>`. The endpoints
we use (all verified against the provided test server):
| Purpose | Endpoint |
| --- | --- |
| List libraries | `GET /api/libraries` |
| List a library's items | `GET /api/libraries/<lib>/items?limit=<n>&sort=…` |
| Search within a library | `GET /api/libraries/<lib>/search?q=<t>&limit=<n>` |
| Item detail (tracks) | `GET /api/items/<item>?expanded=1` |
| **Stream a file** | `GET /api/items/<item>/file/<ino>?token=<key>` |
Objects (fields we read):
- **library**: `id`, `name`, `mediaType` (we handle `book`).
- **item summary** (in the items list): `id`, `mediaType`, `media.metadata`
(`title`, `authorName`), `media.numAudioFiles`, `media.duration`.
- **item detail**: `media.metadata.{title,authorName}` and `media.tracks[]`,
each track: `index`, `title` (the filename, e.g. `Neuromancer-01.opus`),
`duration` (seconds, float), **`ino`** (stable file id, an integer string),
`contentUrl` (`/api/items/<item>/file/<ino>` — same components as the path).
- **search** response: `{ "book": [ { "libraryItem": <item> }, … ], … }`.
Verified facts that shape the design: the file endpoint returns `200` with
`?token=` and `401` without; a `Range` request returns `206`; ebook-only items
report `numAudioFiles == 0`.
## Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by `/youtube` and `/fyyd`): mirroring tidal's search-term
semantics costs no TUI or wire change. No proto change, no new
`ProviderCommand`.
- ABS needs credentials, so — unlike fyyd — a usable `abs.toml` must carry a
`base_url` and an `api_key`. A missing file, or one lacking either field,
**disables the provider non-fatally** (like fyyd/youtube on a failed probe):
it only costs the `/abs` subtree, never startup.
- The audio player streams the `?token=` file URL directly (its windowed-HTTP
path): ABS serves the raw file with range support, so there is no
`/youtube`-style URL-lifetime or ~1 MiB-cap problem. Audiobook files are
ordinary media (opus/mp3/m4a/flac) the existing decoder already handles.
- Items and files are addressed by their **ABS ids**: the library id and item
id are UUIDs, and the file `ino` is an integer string — all already
URL-safe. Only user-typed **search terms** are percent-encoded.
- The stream URL embeds a secret (`?token=`), so it must **never** be logged;
and `api_key` must be redacted from `Debug`/config dumps (hard rule:
redact secrets from logs and error reports).
## Decisions
### D1 — Crate `absdy`, mounted at `/abs`, non-fatal init
New workspace crate `absdy` implementing `ProviderClient`, shaped on `fyyd`
(the closest analog: remote, search-driven, one extra container level, plain
streamable URLs). Wired into `ProviderOrchestrator` with an
`abs_client: Option<Arc<absdy::Client>>` field, `abs_owns()`/`abs_provider()`
helpers, a `build()` block that reads `abs.toml` (non-fatal), a `get_lib_root`
child gated on `self.abs_client.is_some()`, and one routing arm in each
dispatch method. `crabidy-server`'s settings gain `abs` in `ALL_PROVIDERS` (now
7), in `ProviderToggles`, in `all()`, and in `provider_toggles()`.
### D2 — HTTP behind a trait, faked in tests
All network access goes through one seam — an `Abs` trait
(`libraries`, `library_items`, `search_items`, `item_detail`) behind
`Box<dyn Abs>` — with a `reqwest`-based `AbsApi` (bearer auth) for production
and a `FakeApi` in tests, exactly as `fyyd` hides `reqwest` behind `Fyyd`.
Provider logic (tree shaping, path parsing, term store, stream-URL building) is
then unit-tested with zero network. The trait's error type maps to
`ProviderError::FetchError` at the boundary; malformed paths are
`MalformedPath`; empty create/rename input is `InvalidInput`; missing
credentials at init are `Config`.
### D3 — Tree shape (libraries, books, tracks, per-library search)
Item ids are UUIDs and file inos are integers, so neither can equal the
reserved segment `search`; the parser uses that to split the two branches.
- `/abs` — children: one node per library from `/api/libraries` (a fixed
browse; the provider is useful with zero typing). Not itself queueable.
- `/abs/<lib>` — children: a reserved `search` child (`is_creatable`) **plus**
the library's items (bounded, see D5) as book children. A book child's
`is_queable` is set from `numAudioFiles > 0`, so ebook-only items show but
are not queueable/capturable.
- `/abs/<lib>/<item>` — lists that book's audio files as **tracks**;
queueable and downloadable (homogeneous tracks).
- `/abs/<lib>/<item>/<ino>` — the track leaf.
- `/abs/<lib>/search``is_creatable`; children are the in-memory search
terms (`RwLock<Vec<String>>`, dedup, recreated implicitly on stale paths),
each `is_editable` + `is_deletable`, like tidal/youtube/fyyd. Search terms
are stored **per library** (keyed by library id).
- `/abs/<lib>/search/<term>` — lists matching **books** as children (same book
shape as a direct library child).
- `/abs/<lib>/search/<term>/<item>` and `.../<item>/<ino>` — identical book
node and track leaf as under the direct browse; the two branches share the
book-node and track-leaf builders.
Terms are percent-encoded into one segment (`encode_segment`/
`decode_segment`); library ids, item ids, and inos are already URL-safe.
### D4 — Streams, metadata, no extra call for playback
- `get_urls_for_track`: parse `item` + `ino` from the path and build
`<base>/api/items/<item>/file/<ino>?token=<key>`. **No API call** — the URL
is fully derivable from the path; the token is appended last and never
logged. An unparseable path is `MalformedPath`.
- Track fields (from the item detail's `tracks[]`): `title` = track title (the
file name), `artist` = the book's `authorName`, `album` = the book title,
`duration` = track duration (seconds → `Option<u32>`), `provider_item_id` =
`"<item>:<ino>"` (stable per file, keys the content store for captures).
- **Metadata source.** Listing a book fetches the item detail once and builds
every track from `tracks[]` (title/duration/author all present). A
*directly* fetched track (`get_metadata_for_track` on a bare track path)
fetches the same item detail and picks the matching `ino`. A missing field
degrades to an empty string / `None`, never an error.
### D5 — Bounds and freshness
- `items_per_library` (default 200), `search_results` (default 50) cap every
listing so a huge library cannot stall the tree or queue resolution;
`call_timeout_secs` (default 30) bounds each HTTP call (hard rule: timeouts
on external calls).
- Listings are fetched fresh per call (no cross-call cache), like the other
remote providers. Only the search *terms* are stored, in memory, per library.
### D6 — Out of scope (explicitly)
- **Podcast** libraries (`mediaType: "podcast"`, `media.episodes`): the test
server has none; the book-node builder reads `media.tracks`. A podcast
library's items would show no tracks (non-queueable). Adding an episodes
branch is a later, additive change — noted as the extension point.
- ABS user accounts beyond the single API key, playback-progress sync back to
ABS, series/authors/collections/genre browse, and tag filtering.
- Transcoding / HLS sessions — we stream the raw file with range requests.
- Cover art, chapters, and ebook reading.
- Pagination past the configured caps (one page is fetched per listing).
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
absdy: "absdy (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory, per library)"
api: "AbsApi\n(reqwest seam: Abs trait,\nbearer auth)"
client -> terms
client -> api
}
abs: "audiobookshelf\n(/api, bearer auth)" { shape: cloud }
player: audio-player { shape: hexagon }
server.orch -> absdy.client: "/abs/..."
absdy.api -> abs: "libraries / items / search / detail (JSON, timeout)"
server.orch -> player: "file URL with ?token="
player -> abs: "windowed HTTP stream (Range → 206)"
```
## Key flow: browse a library, play a track
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
a: absdy
api: audiobookshelf
tui -> orch: "open /abs"
orch -> a: "get_lib_root / get_lib_node"
a -> api: "GET /api/libraries"
api -> a: "libraries (id, name)"
a -> tui: "libraries as children"
tui -> orch: "open a library"
orch -> a: "get_lib_node(/abs/<lib>)"
a -> api: "GET /api/libraries/<lib>/items"
api -> a: "items (id, title, numAudioFiles)"
a -> tui: "[search] + books as children"
tui -> orch: "open a book"
orch -> a: "get_lib_node(/abs/<lib>/<item>)"
a -> api: "GET /api/items/<item>?expanded=1"
api -> a: "tracks (ino, title, duration)"
a -> tui: "book node: files as tracks (queueable)"
tui -> orch: "queue + play a track"
orch -> a: "get_urls_for_track(.../<ino>)"
a -> a: "build /api/items/<item>/file/<ino>?token= (no call)"
orch -> orch: "player streams the file"
```
## Risks and open questions
- **API-key lifetime.** ABS API keys are long-lived tokens, but if the
configured value is a short-lived session JWT it will eventually expire; a
`401` then surfaces as a typed `FetchError` (a skipped track / an unreadable
node), never a crash. Re-issue the key in `abs.toml` to recover.
- **Token leakage.** The stream URL embeds the key. It must never reach a log,
trace, or error report — enforced by building the URL only at the boundary,
a redacting `Debug`, and logging paths/context (never the built URL). This
is a quality gate.
- **Summary vs detail drift.** A book child's `is_queable` comes from the
summary's `numAudioFiles`; the actual track count comes from the detail.
A mismatch only means an optimistic flag — resolution of an empty book
yields no tracks (skipped), never an error.
- **Large libraries.** Capped by `items_per_library`; deep browsing past the
cap needs pagination (out of scope). The cap is `log`-ged so truncation is
visible, not silent.
- **Field / envelope drift across ABS versions.** DTOs decode defensively
(`#[serde(default)]`, missing fields degrade); a renamed field is a local
fix in `AbsApi`. **Live validation is a task-plan gate** (already exercised
during design against the test server).

View File

@ -1,188 +0,0 @@
# Bookmarks: capturing library subtrees
> **Superseded** by `crabidy-store.md`: `/bookmarks` is folded into the single
> `/crabidy` provider; a `w`-save writes link tomls into a `/crabidy/<name>`
> folder. Kept for the link-vs-store rationale.
## Context and problem statement
Queue persistence (architecture/queue-persistence.md) flattens the queue
into one folder of link files. The user now wants to **capture whole
subtrees**: pressing `w` on a queueable item in the *library* (an artist,
an album, a playlist folder) snapshots it into a local tree that
**preserves structure** — an artist becomes a folder of album folders,
each holding the playable track files. The store is one more local fs
provider ("bookmarks"); replaying is plain fs-provider behavior. On top,
the created top-level folders must be **renamable and deletable** from
the TUI — and the same should hold for saved queues.
## Assumptions (confirmed against the code)
- `fsdy::Client` is instance-mountable since queue persistence
(`Client::new(provider_root, disk_root)`); a third instance is cheap.
- The TUI already routes `e`/`d` through `is_editable`/`is_deletable`
flags on `LibraryNodeChild` into the existing
`RenameLibraryNode`/`DeleteLibraryNode` rpcs, and the orchestrator
already routes those to the `/queues` (and future `/bookmarks`)
instances. Making folders modifiable therefore needs **zero TUI
changes** — only fsdy must set flags and implement rename/delete.
- The capture walk can reuse `get_lib_node` through the
`ProviderOrchestrator` (any provider reachable), and
`TrackFile::from_track` + `track_file_name` from queue persistence for
the leaves.
- `w` is unbound in the TUI's `Library` scope; the input overlay handles
ask-for-a-name flows and supports prefilling (rename does).
## Decisions
### D1 — Third fsdy instance `/bookmarks`; capture is a server-side walk
A `BookmarkStore` (sibling of `QueueStore`) owns
`<config>/crabidy/bookmarks/`; the orchestrator mounts a read-only fsdy
instance over it at `/bookmarks` and is the only writer. Capture runs on
the **orchestrator side** (it must call `get_lib_node` across providers):
a new `ProviderCommand::CaptureLibraryNode { path, name }` is handled on
a spawned task (like `ResolveTracks` — a big artist walk must not block
the loop). The reply arrives when the write finished.
Not chosen: capturing client-side in the TUI (would duplicate provider
access) or reusing the playback loop (captures are not queue state).
### D2 — Structure fidelity: order-prefixed folders and files
The walk mirrors the subtree iteratively (worklist, pre-order):
- Each child **node** becomes a folder named `NNNN <title>` (same
zero-padded prefix and sanitizer as queue entries, no suffix) — the
case-insensitive listing sort then reproduces the provider's child
order, which is meaningful (album track order, discography order).
- Each **track** becomes `NNNN <title>.cbd-track.toml` via the existing
`TrackFile::from_track` (uniform link playable) — metadata is captured
at save time; drift is accepted like everywhere else.
- A node carrying both tracks and children (search terms) writes both.
- Capturing a *track* selection is allowed: a folder with one link file.
- Whole-bookmark writes are tmp-and-swap like queues; an existing
bookmark of the same name is overwritten.
**Safety caps**: the walk aborts (typed error, temp dir removed) beyond
1 000 directories or 20 000 tracks — a runaway provider tree must not
fill the disk. Cycles are impossible under the cap (it bounds total
nodes, not depth). Since captured listings rewrite link tracks to their
targets, capturing a bookmark re-links to the *original* targets — no
link chains ever get written.
### D3 — New rpc `CaptureLibraryNode(path, name)`
Additive proto change (the only wire change). Name validation is shared
with queue saving (trimmed, no separators/NUL, no leading dot; no
reserved names in `/bookmarks`). Mapping: invalid name/source →
`invalid_argument`, capture disabled (no config dir) →
`failed_precondition`, walk/write failures → `internal`. The response is
empty — the TUI stays where it is (unlike `%`-create, capturing is not a
navigation; the bookmark appears under `/bookmarks` on the next visit).
### D4 — Mutable top-level folders as an fsdy instance option
`fsdy::Client` gains a builder option
`with_editable_top_level(reserved_names)`:
- The instance-root listing marks child *folders* `is_editable` and
`is_deletable`, except reserved names.
- `rename_lib_node`: only direct children of the instance root; new
title validated like a store name; renaming onto an existing sibling
is `InvalidInput` (folders never merge); returns the renamed node (the
TUI navigates into it, as with search terms).
- `delete_lib_node`: only direct children of the root; `remove_dir_all`;
idempotent (already gone → success); returns the refreshed root
listing.
- Deeper levels stay immutable — the user request covers the *created*
folders; restructuring inside a capture is file-manager work.
Applied to `/bookmarks` (no reserved names) **and `/queues`** (reserved:
`current`, which auto-persist owns — it can be neither renamed nor
deleted, and nothing can be renamed onto it). `/fs` keeps the immutable
default. Not chosen: implementing rename/delete in the stores — the
providers already own path→disk mapping and the rpc routing exists.
### D5 — TUI: `w` in the library scope
`Action::LibraryCaptureNode` bound to `w` in `Scope::Library` ("Save
selection as bookmark"): opens the input overlay **prefilled with the
selected item's title**, gated on the bare selection being queueable
(marks are ignored — one capture per invocation). Submit sends
`MessageFromUi::CaptureNode { path, name }` → the new rpc. Rename (`e`)
and delete (`d`) of bookmark/queue folders ride the existing flows via
the D4 flags.
### D6 — Out of scope (explicitly)
- Capturing multiple marked items at once; capture progress display.
- Rename/delete below the top level; moving bookmarks between folders.
- Refreshing a bookmark from its source (re-capture under the same name
overwrites — that *is* the refresh).
- A creatable `/bookmarks` root (`%`) — bookmarks are created from the
source tree.
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator {
cap: "capture walk (spawned):\nget_lib_node -> mirror tree"
}
bstore: BookmarkStore {
w: "validate name, caps,\ntmp-and-swap"
}
}
tidal: tidaldy
fs: "fsdy /fs"
qfs: "fsdy /queues\n(editable top level,\nreserved: current)"
bfs: "fsdy /bookmarks\n(editable top level)"
disk: "config/crabidy/bookmarks" {
shape: cylinder
tree: "<name>/NNNN <album>/NNNN <track>.cbd-track.toml"
}
server.orch.cap -> tidal: "walk source subtree"
server.orch.cap -> server.bstore: "write mirrored tree"
server.bstore -> disk
bfs -> disk: "list + parse (read only)"
server.orch -> bfs: "/bookmarks/... (browse, queue, e/d)"
server.orch -> qfs: "e/d on saved queues"
```
## Key flow: capture an artist, rename it, replay an album
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
tidal: tidaldy
store: BookmarkStore
tui -> orch: "CaptureLibraryNode(/tidal/artists/42, faves)"
orch -> tidal: "get_lib_node (artist, albums, ...)"
orch -> store: "write faves/0001 Album/0001 Song.cbd-track.toml ..."
store -> tui: OK
tui -> orch: "RenameLibraryNode(/bookmarks/faves, road faves)"
orch -> tui: "renamed node (TUI navigates in)"
tui -> orch: "ReplaceQueue([/bookmarks/road%20faves/0001%20Album])"
orch -> tui: "resolve walk streams the album's tracks"
```
## Risks and open questions
- **Capture duration**: a large artist means many provider fetches; the
TUI's poll loop awaits the rpc like other slow calls (accepted,
consistent with search-term creation). The orchestrator loop itself
stays free (spawned task).
- **Rename/delete racing a re-capture** of the same name: last writer
wins on the swap; accepted for a single-user local server.
- **Prefix width** (9999 entries per folder) shared with queues;
accepted.
- Open (future): re-capture/refresh command; capturing marked sets;
editable nesting.

View File

@ -1,400 +0,0 @@
# Build features (tailored, non-bloated builds)
Cargo features that let a build drop whole subsystems — each provider,
Opus decoding, the spectrum, the embedded web UI, desktop notifications —
together with the dependencies those subsystems pull in. Everything is
**on by default**: a plain `cargo build` produces today's binary, and
`--no-default-features --features …` produces an appliance build.
## Context and problem statement
`crabidy-server` links every provider unconditionally: `tidaldy`,
`ytdy` (and through it `rustypipe`), `fyyd`, `absdy`, `soundclouddy`,
`jamendody`, `fsdy`. `audio-player` always links `symphonia` plus
`symphonia-adapter-libopus`, which **bundles libopus and therefore
requires `cmake` + `ninja` at build time**. `cbd-tui` always links
`notify-rust` (a D-Bus stack on Linux). A user who wants "a Raspberry Pi
that plays my local flac collection" compiles, links, and ships all of
it.
There is already a **runtime** switch — `crabidy-server.toml`'s
`providers = [...]` list ([`settings::ProviderToggles`]) — but it only
decides what gets *mounted*; every dependency is still compiled and
linked. The ask is the compile-time half, aligned with the dependency
graph so a tailored build is genuinely smaller.
Two questions from the request are answered here: `scan` **does**
already treat `.opus` as playable (`cli.rs: AUDIO_EXTENSIONS`), and that
entry now becomes conditional on the `opus` feature (D6); and disabling
`fs` also drops `/crabidy` and `/orphans` (D5).
## Assumptions (decided)
- **Default-on, opt-out.** No user's build changes unless they ask. The
entry point for tailoring is `--no-default-features`.
- **Two layers, different jobs.** Compile-time features decide what is
*linked*; the existing `providers` list decides what is *mounted*.
A provider that is compiled in can still be turned off in the toml;
a provider that is not compiled in cannot be turned on.
- **The wire protocol is feature-independent.** No `.proto` changes, no
feature-conditional RPCs, no client/server feature coupling. A client
asking for `/tidal` on a tidal-less server gets exactly what it gets
today from a runtime-disabled provider (`MalformedPath` → gRPC
`InvalidArgument`), and the root listing simply does not offer it.
Clients (`cbd-tui`, `cbd-web`) need **no** knowledge of server
features.
- **A feature must pay for itself in dependencies.** Adding `#[cfg]`
noise to gate code that shares its dependencies with code that stays
is a net loss — that is what the runtime toggles are for. See D5 for
where this bites (`crabidy`/`orphans`) and D9 for what we refuse to
gate.
## Options considered
### How to gate the providers inside the orchestrator
`ProviderOrchestrator` holds nine `Option<Arc<ConcreteClient>>` fields
and dispatches with a hand-written `if …_owns(path) { … }` chain,
repeated verbatim across eight trait methods (`is_track_path`,
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
`resolve_tracks_into`) plus `get_lib_root`.
1. **Sprinkle `#[cfg(feature = …)]`** on every field, every
`*_owns`/`*_provider` helper, and every branch of every chain:
~130 attributes, nine of them per method body, in a 1000-line file
that then only compiles in one shape per feature combination.
Rejected: unmaintainable, and each new provider multiplies it.
2. **A mount registry** (chosen). The nine dispatch chains collapse into
one lookup, because every branch is already the *same* code modulo
the client. `ProviderClient` is dyn-compatible (its only non-`&self`
method, `init`, carries `where Self: Sized`), so mounts can be held
as `Arc<dyn ProviderClient>`:
```rust
struct Mount {
root: &'static str, // "/tidal", "/fs", …
name: &'static str, // root-listing title
client: Arc<dyn ProviderClient>,
}
```
Dispatch becomes "find the mount whose root owns this path, or
`MalformedPath`". A provider is then gated in exactly **one** place —
its registration in `build()` — plus its `Cargo.toml` line. This
deletes ~600 lines of repetition and is a strict prerequisite for the
feature work, so it lands first, on its own, with behaviour
unchanged.
Ordering of the root listing (crabidy first, orphans last, rest
alphabetical) is a property of the assembled child list and is
preserved by the registry (it sorts the same way).
### Where the feature flags live
`crabidy-server` is the hub and owns the user-facing names.
`audio-player` gets internal features that the server turns on
(`opus`, `hls`, `spectrum`); `cbd` (the bundle) forwards the server's
set so `-p cbd` is tailorable too; `cbd-tui` owns `notifications`.
`crabidy-core` stays feature-free — it is the shared proto/trait crate
and every configuration needs all of it.
## Decisions
**D1 — Feature set.** `crabidy-server`:
```toml
[features]
default = ["all-providers", "opus", "spectrum", "web-ui"]
all-providers = ["tidal", "youtube", "fyyd", "abs", "soundcloud",
"jamendo", "fs"]
tidal = ["dep:tidaldy"]
youtube = ["dep:ytdy"]
fyyd = ["dep:fyyd"]
abs = ["dep:absdy"]
soundcloud = ["dep:soundclouddy"]
jamendo = ["dep:jamendody"]
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest"]
opus = ["audio-player/opus"]
spectrum = ["dep:realfft"]
web-ui = ["dep:tonic-web", "dep:include_dir"]
```
`cbd` mirrors every one of them as a pass-through
(`tidal = ["crabidy-server/tidal"]`, …) and adds
`notifications = ["cbd-tui/notifications"]`. `cbd-tui`:
`default = ["notifications"]`, `notifications = ["dep:notify-rust"]`.
`audio-player` gets exactly one feature — `default = ["opus"]`,
`opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]` — for the
reason in D7.
**D2 — A build with no providers is legal.** `--no-default-features`
must compile and run: the server starts, serves an empty library root,
and plays nothing. It is the base case of the matrix (D11) and the
cheapest possible smoke test of the gating. It is not a *useful*
deployment, and the startup log says so.
**D3 — Names match the runtime toggles.** The feature names are exactly
the strings in `providers = [...]` (`tidal`, `youtube`, `fyyd`, `abs`,
`soundcloud`, `jamendo`, `fs`). One vocabulary for both layers.
**D4 — The compiled-in set is discoverable.** `settings` gains a
compile-time `BUILT_IN_PROVIDERS` (the feature-filtered version of
today's `ALL_PROVIDERS`). Consequences:
- the default `crabidy-server.toml` written on first run lists only
providers this binary has;
- a name in the user's list that this binary lacks logs one clear
warning at startup (`providers lists "tidal", but this binary was
built without it`) — a warning, not a startup abort, because the
library layer is fail-open by design (unlike `[auth]`);
- an unknown name (a typo) warns the same way;
- `crabidy-server features` / `cbd features` prints the compiled set
(providers + `opus`/`spectrum`/`web-ui`/`notifications`), and the
same list goes into one `info!` line at startup. "Why is `/tidal`
missing?" is then answerable from the binary and the log.
**D5 — `fs` owns local files *and* persistent state; `crabidy` and
`orphans` stay runtime-only.** As requested, disabling `fs` disables
`/crabidy` and `/orphans` too — but they are not separate *features*,
because they add no dependency of their own: `crabidy_store.rs`,
`capture.rs`, and `orphans.rs` are all written in terms of `fsdy` types
(`fsdy::TrackFile`, `Playable`, `AlbumMeta`, `dir_name`). Gating them
separately would buy nothing and cost three more `#[cfg]` dimensions.
So the `fs` feature is one coherent unit — "local files and persistent
state" — covering:
| gated by `fs` | consequence when off |
| --- | --- |
| `fsdy` client, `/fs` mount | no `/fs` |
| `crabidy_store` + `/crabidy` mount | no saved queues, bookmarks, captures |
| `orphans` + `/orphans` mount | no GC view |
| queue persistence | in-memory only; a restart starts empty |
| `capture` (bookmarks, downloads) | capture/save RPCs `Unimplemented` |
| `annotate_captured` | no captured markers (clients handle absent flags) |
| the `scan` CLI command | fails with "built without the `fs` feature" |
Users who want `/fs` but not `/crabidy` keep doing what they do today:
prune the `providers` list.
**D6 — `opus` is the biggest single win and is not tied to a provider.**
It drops `symphonia`, `symphonia-adapter-libopus`, and with them the
bundled libopus C build (`cmake` + `ninja` disappear from the build
requirements — the reason this feature is worth its `#[cfg]`s).
Ogg-Opus files reach the player from `/abs`, `/fs`, and `/crabidy`
alike, so it stays an independent axis. With `opus` off:
- `player_engine::build_source` skips the sniff and hands everything to
rodio's decoder — an Ogg-Opus file then fails to decode with a clear
error (`this build has no Opus decoder`) and playback skips the track,
exactly as any undecodable file does today. **No panic** (hard rule).
- `cli.rs: AUDIO_EXTENSIONS` drops `"opus"`, so `scan` no longer indexes
`.opus` files it could not play. (It *does* index them today; that is
correct behaviour for a build that has the decoder.)
**D7 — HLS playback is *not* gated.** SoundCloud is the only provider
that returns an `.m3u8` (`soundclouddy` resolves HLS media URLs), so
`audio-player/src/hls.rs` is dead code in a build without `soundcloud`
— but it imports only crates the player needs anyway (`bytes`,
`futures`, `stream-download`, `url`, `reqwest`). Gating it would buy a
few KB of code and cost a `#[cfg]` dimension across the decode path, so
it stays unconditional. Same reasoning for the windowed-HTTP source. The
rule this follows is the one in the assumptions: **a feature must pay
for itself in dependencies.**
**D8 — `spectrum` gates the server side only.** `realfft`, `spectrum.rs`
and `spawn_spectrum_task` go away; the `SpectrumFrame` proto message,
the player's sample tap, and both clients' rendering stay. A client
subscribed to the update stream simply never receives a frame, which it
already handles (the bars stay dark). The tap itself
(`audio-player/src/spectrum_tap.rs`) is not gated — like `hls.rs` it
brings no dependency (std + rodio), and it is woven through
`player_engine`'s decode path via `TappingSource`, so `Player`'s public
surface stays feature-invariant.
**D9 — Deliberately *not* behind features.**
- **`[auth]` / `argon2`.** A binary built without auth would ignore
configured role hashes and run open — a fail-open security hole for a
~200 KB dependency. Refused. (If it is ever added, it must *abort*
startup when `[auth]` is non-empty.)
- **Audio output / `rodio` / ALSA.** The server *is* the player; a
server with no audio output has no purpose here.
- **`web-ui` on the clients.** `cbd-web` is its own crate; not building
it is already the way to not have it.
- **TUI spectrum rendering, TUI/web feature parity.** No dependency
behind them (D8).
- **The CLI surface.** The clap definitions live in `cbd-cli`, which
depends on none of the gated crates. Keeping the surface constant
means completions and the man page do not vary per build; a command
whose backing feature is absent fails with a clear message (D5).
**D10 — `flake.nix` must be updated in the same change.** Its native
build passes a bare `--no-default-features` (today: "everything except
`web-ui`"). After D1 that would silently produce a **provider-less**
binary. It becomes an explicit list — `--no-default-features --features
all-providers,opus,spectrum` — and the aarch64 cross build (full
defaults, `web-ui` on) stays as is. `devenv.nix` gains scripts for the
tailored builds so the matrix is one command.
**D11 — Verification is a build matrix, not a powerset.** Feature
combinatorics are the real risk: unused imports/dead code under odd
combinations, and `-D warnings` in the pre-commit hook. A curated matrix
(D2's empty build, defaults, each provider alone, `fs`-only,
`opus`-off, `spectrum`-off, `web-ui`-off, and the two client crates)
gives the coverage that matters; `cargo hack --each-feature` is
available in devenv for a deeper sweep when the flags change.
**D12 — `opus-bundled` splits *decoding Opus* from *vendoring libopus*
(added 2026-07-28).** D6 treated the two as one axis, so the only way to
avoid the vendored C build was to give up Opus playback. That is the
wrong trade for a packaged build: Nix, and any distribution, would rather
link the libopus it already ships than run cmake inside the sandbox.
The adapter crate already draws this line — `symphonia-adapter-libopus`'s
`bundled` feature is what pulls `opusic-sys/bundled` and with it the
`cmake` crate — so the split costs one feature and no code:
```toml
# audio-player
default = ["opus", "opus-bundled"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
opus-bundled = ["symphonia-adapter-libopus?/bundled"]
```
with the workspace dependency declared `default-features = false` and
pass-throughs up through `crabidy-server` and `cbd`. Two details are
load-bearing:
- The **weak** `?/` in `opus-bundled`. A plain `symphonia-adapter-libopus/bundled`
would enable the optional dependency itself, so `opus-bundled` alone
would silently switch Opus decoding back on — turning a "where does the
library come from" flag into a second "do we decode Opus" flag.
- `opus-bundled` stays in `default`, so a plain `cargo build` still needs
nothing installed. Opting *out* is the packager's explicit act, not the
developer's default.
Consequences for the Nix builds (D10): the native package drops `cmake`,
takes `libopus` as a `buildInput`, and leaves `opus-bundled` out of its
feature list. The aarch64 cross build keeps the vendored copy — it links
statically, and an unbundled `-lopus` would need a static aarch64 libopus
staged for the target the way `alsa-lib` is. `devenv.nix` sets
`OPUS_LIB_DIR` so the unbundled path links in the dev shell too, where the
linker knows nothing of the store.
**D13 — the native Nix package sets its own RUNPATH (added
2026-07-28).** Uncovered by D12: rustc stamps no RUNPATH, and the `-L`
that `buildInputs` contributes reaches the linker through `NIX_LDFLAGS`,
which ld-wrapper does not mirror into the binary. The installed binaries
therefore linked cleanly and then refused to *start* wherever the
libraries were not already on `LD_LIBRARY_PATH` — a latent bug for
`libasound` since the package was first written, which the added
`libopus` only made louder. `autoPatchelfHook` on the final derivation
fills the RUNPATH from `buildInputs` (plus `stdenv.cc.cc`'s `libgcc_s`,
which is the compiler's own runtime and so declared by no crate) and
fails the build on anything it cannot resolve. Verified by running each
binary under `env -i`.
## Structure
```d2
direction: right
features: crabidy-server features {
providers: "tidal · youtube · fyyd\nabs · soundcloud · jamendo · fs"
opus: opus
spectrum: spectrum
webui: web-ui
}
server: crabidy-server {
registry: "mount registry\nArc<dyn ProviderClient>"
store: "crabidy_store + capture\n+ orphans"
fft: "spectrum.rs (realfft)"
web: "web.rs (tonic-web,\ninclude_dir)"
}
player: audio-player {
ap_opus: "opus_source\n(symphonia + libopus,\nneeds cmake)"
ap_rest: "hls · windowed_http\n· spectrum_tap\n(never gated: no own deps)"
}
deps: provider crates {
tidaldy
ytdy: "ytdy → rustypipe"
fyyd
absdy
soundclouddy
jamendody
fsdy: "fsdy + blake3"
}
features.providers -> server.registry: mounts
features.providers -> deps: "dep:*"
features.providers -> server.store: "fs only"
features.opus -> player.ap_opus
features.spectrum -> server.fft
features.webui -> server.web
```
Dispatch after the registry refactor — one path, whatever is compiled
in:
```d2
direction: right
rpc: RPC / provider loop
root: "path == /"
lookup: "mount whose root owns the path"
client: "Arc<dyn ProviderClient>"
none: "MalformedPath / NotSupported"
listing: "root listing\n(crabidy, …, orphans)"
rpc -> root
root -> listing: yes
root -> lookup: no
lookup -> client: found
lookup -> none: no owner
```
## Boundaries and interfaces
- **`ProviderOrchestrator`** — the only place a provider is named. New
shape: `mounts: Vec<Mount>` plus the `Option<Arc<CrabidyStore>>` that
`fs` brings (the store is not a mount; it is a writer other
subsystems share). Public surface (`build`, `run`, `provider_tx`,
`crabidy_store`, the `ProviderClient` impl) is unchanged.
- **`settings`** — `BUILT_IN_PROVIDERS` (compile-time) and
`ProviderToggles` (runtime) meet here; `provider_toggles()` returns
toggles only for providers this binary has.
- **`audio-player`** — public API is feature-invariant (`Player`,
`PlayerMessage`, `SpectrumTap`, `output_device_names`); features
change only what is inside.
- **Proto / clients** — untouched.
## Risks
- **`#[cfg]` rot.** A combination nobody builds breaks silently.
Mitigated by D11's matrix in devenv scripts (and CI when there is
one).
- **Startup surprise.** A user upgrading a distro package built without
`youtube` sees `/youtube` vanish with no clue. Mitigated by D4
(startup log line, `features` command, warning when the toml names a
provider the binary lacks).
- **`flake.nix` silently shipping an empty build.** D10; it is the first
thing the plan changes after the manifests.
- **The registry refactor touching every dispatch path.** Landed as its
own commit with no feature changes, so a regression bisects cleanly.
`resolve_tracks_into`'s per-provider overrides keep working — it is a
trait method, dispatched dynamically like the rest.
- **Opus files in an opus-less build.** They fail to decode and are
skipped, with a message naming the missing feature (D6) — never a
panic.
## Open questions
None blocking. Deferred by choice: gating `[auth]` (D9, refused),
per-provider *runtime* dynamic loading (out of scope — features are
compile-time), and a `minimal` convenience feature (users compose
`--no-default-features --features fs,opus` instead).

View File

@ -1,102 +0,0 @@
# Capture deletion
> **Superseded** by `crabidy-store.md`: audio now lives in a shared store that
> track deletion never touches, so deletion on `/crabidy` goes through directly
> with no confirmation and no disk reclamation. This whole feature is removed.
Deleting under `/captures` reclaims disk: downloaded audio is the one
library content that is expensive to recreate (slow, throttled downloads
— architecture/youtube-rustypipe.md), so stale captures must be
removable from the TUI, and removal must actually delete the files.
## Context
Before this feature, deletion (`d`) was limited to *top-level* folders
of editable fsdy instances (architecture/bookmarks.md D4): whole
captures could be deleted (and were removed from disk via
`remove_dir_all`), but nothing below — no single album, no single
track. Deletes were deliberately unconfirmed because every deletable
node was cheap to recreate.
## Decisions
### D1 — deletable tree as an fsdy instance option
`fsdy::Client::with_deletable_tree()` makes every folder below the
instance root deletable (recursively, any depth) and every track file
deletable. Only the `/captures` instance sets it:
- `/queues` and `/bookmarks` keep the top-level-only contract; their
nested structure mirrors a snapshot and partial edits are better done
by re-saving.
- Nested folders become deletable but **not renamable** — renames would
break the incremental-capture merge by name
(architecture/incremental-captures.md), deletes cannot: a re-capture
of the same name simply re-downloads what is missing.
- The instance root itself and reserved top-level names stay
undeletable even on a deletable tree.
### D2 — tracks advertise deletability through their node
Wire truth, not client guessing: the TUI must not hardcode which tracks
are deletable. But a per-`Track` flag would touch every Track literal
in every provider for a capability only fsdy uses. Instead
`LibraryNode.tracks_deletable` says "this node's listed tracks may be
deleted", exactly like the existing `is_downloadable` inheritance
("tracks inherit their node's blessing", architecture/captures.md D4).
Child folders keep using the existing per-child `is_deletable`.
### D3 — a deleted track takes its audio with it, inside the root only
Deleting a track file removes the `.cbd-track.toml` **and** the audio
its `[playable] file` points to — that is the point of the feature.
Safety boundary: the audio path (relative values resolved against the
track file's directory) is canonicalized and must live inside the
canonicalized instance root; anything else is kept and logged. So a
hand-written track file referencing `~/Music/song.flac` from inside the
captures folder can never delete foreign data, and `..`/symlink tricks
resolve before the check. A track file that no longer parses is deleted
blind (its audio cannot be located; the listing skipped it anyway).
Deletes stay idempotent per the proto contract.
### D4 — confirmation in the client, scoped to /captures
`d` on anything under `/captures` opens a one-line modal prompt
(`delete <title>? [y/N]`, red) instead of sending; only `y`/`Y`
confirms, any other key cancels. Every other deletable (search terms,
bookmarks, saved queues) stays a single unconfirmed keypress
(architecture/node-editing.md D4) — they are cheap to recreate, and a
blanket confirmation would train reflexive `y`. The scoping is a path
check in the TUI (like the `/captures` never-cache rule in
`cbd-tui/src/rpc.rs`): the server does not know which deletes a client
should consider expensive.
## Flow
```d2
direction: right
tui: cbd-tui {
d: "d on /captures/…"
confirm: "delete …? [y/N]"
d -> confirm
}
server: crabidy-server {
provider_loop: provider loop
}
fsdy: fsdy /captures instance {
folder: "folder: remove_dir_all"
track: "track: toml + contained audio"
}
tui.confirm -> server.provider_loop: y → DeleteLibraryNode
server.provider_loop -> fsdy.folder
server.provider_loop -> fsdy.track
fsdy.folder -> tui: refreshed parent listing
```
## Risks / notes
- The confirmation prompt occupies the same line as the text-input
overlay; both are strictly modal and never open together.
- Deleting the folder of a *running* capture is possible; the capture
walk recreates directories as it goes and re-downloads on the next
run, so the race wastes bandwidth but corrupts nothing.

View File

@ -1,231 +0,0 @@
# Captures (downloaded subtrees)
> **Superseded** by `crabidy-store.md`: captures now link into a shared
> content-addressed store under `~/.local/share/crabidy/`, de-duplicated by
> provider id and by content hash, instead of downloading audio next to each
> toml; `/captures` folds into `/crabidy`. Before that, download captures were
> already **partially superseded** by `incremental-captures.md`: they became
> incremental (re-capturing a name resumes; no tmp-and-swap), uncapturable
> tracks are recorded as *skipped* tomls instead of omitted, and the capture
> RPC streams progress. Bookmarks kept the tmp-and-swap described here.
## Context and problem statement
Bookmarks (`w`) mirror a library subtree as **link** files — replaying a
bookmark still needs the original provider. The user wants `W` on a library
node to do the same capture into a separate local provider called
**captures**, except each track's audio is **downloaded** next to its
`.cbd-track.toml`, and the toml points at that file. Playback of a capture
then needs no provider round trip at all — it is a fully local copy.
A library node decides whether it allows `W`; Tidal implements it.
## Assumptions (confirmed against the code)
- `PlayableSpec.file` already supports **relative** paths, resolved against
the track file's directory at `get_urls_for_track` time (fs-provider D3).
A toml next to its audio file can say `file = "0001 Song.flac"` and the
whole capture folder stays relocatable (tmp-and-swap, rename, backup).
- `fsdy::list_dir` only surfaces directories and `*.cbd-track.toml` files;
downloaded audio siblings are invisible to the library listing.
- The bookmark walk (`bookmark_store::write_capture`) is an iterative
pre-order worklist whose only per-track action is "serialize and write
one file" — exactly the seam where a download variant plugs in.
- `reqwest` (rustls, `stream`) is already a workspace dependency; tidal
stream URLs come from `get_urls_for_track` on the orchestrator, so the
download needs no new provider methods.
- Uppercase bindings (`K`, `J`) already exist in the TUI bindings table;
`W` in `Scope::Library` is free.
- `LibraryNode`/`LibraryNodeChild` already model per-node capabilities
(`is_queable`, `is_creatable`, …) — the "does this node allow `W`"
decision extends that pattern.
## Decisions
### D1 — Fourth `fsdy` instance at `/captures`
`<config>/crabidy/captures/` is mounted read-only as `/captures` with an
editable top level (no reserved names), exactly like `/bookmarks`. Init is
non-fatal: an unopenable store disables `W` and the mount, never the
server. Loading a capture is browsing `/captures` and queueing a folder —
zero new replay mechanisms.
### D2 — One shared walk, two track sinks
Options considered:
- *(a)* Copy `bookmark_store.rs` and swap the per-track write.
- *(b)* Extract the walk into a shared `capture` module parameterized by a
**track sink**; bookmarks and captures become thin stores over it.
**Decision: (b)** — the walk (worklist, caps, temp-and-swap, all-or-nothing
cleanup, `BadSource` mapping) is behavior we already tested once and must
not fork. `crabidy-server/src/capture.rs` owns `CaptureError`, the caps,
and `write_tree(client, source, tmp, caps, sink)`; the sink is an enum
(no async-trait indirection):
- `Sink::Link` — today's bookmark behavior, byte-identical
(`TrackFile::from_track`, link playable).
- `Sink::Download(Downloader)` — captures (D3).
`BookmarkStore` keeps its API; `CaptureStore` (in `capture_store.rs`) is
its sibling over the captures directory.
### D3 — Download sink: audio next to the toml, toml points at it
Per track, in listing order:
1. `get_urls_for_track` through the orchestrator (any provider that yields
URLs works; Tidal is the target). First URL wins.
2. HTTP GET via one shared `reqwest` client — connect timeout, one total
per-track deadline covering the whole body, **no retries** (a capture
is re-runnable and overwrite = refresh; a retry policy can come later).
The body is streamed to `NNNN <title>.<ext>` (shared `ordered_name`
sanitizer, same 4-digit prefix as the toml so the pair sorts together).
3. The extension comes from the response `Content-Type`
(`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`,
`audio/mpeg``mp3`, `audio/ogg``ogg`, `audio/wav``wav`),
falling back to the URL path's extension, then `bin` (the player probes
by content; the extension is a hint).
4. The toml is written **after** the download succeeds, with
`playable.file = "<audio file name>"` (relative, new
`TrackFile::from_track_with_file`), keeping metadata identical to a
bookmark entry.
Caps: `MAX_CAPTURE_DIRS` stays 1 000; downloads get their own
`MAX_DOWNLOAD_TRACKS = 500` and a total byte budget
`MAX_DOWNLOAD_BYTES = 4 GiB` counted while streaming — a runaway artist
capture must not fill the disk. Downloads run sequentially (gentle on the
provider, trivially bounded memory); the whole capture already runs on a
spawned task, so the orchestrator keeps serving.
All-or-nothing is kept for real failures: any failed download (bad
status, transport error, timeout) aborts the capture and removes the temp
folder. The one softening: a track whose source **cannot be captured at
all** — its stream fails to resolve, or resolves to a non-http(s) target
(a local file playable) — is *skipped* with a warning instead of
aborting. Queue and bookmark captures mix providers (D4), and one local
`/fs` entry must not kill the downloadable rest; the skipped track simply
has no pair in the capture.
### D4 — Nodes opt in via `is_downloadable`
New proto fields `LibraryNode.is_downloadable = 8` and
`LibraryNodeChild.is_downloadable = 7` (additive). Tidal sets the flag
centrally at the end of `get_lib_node`: a node is downloadable when it is
**queueable or lists tracks** (the "or lists tracks" covers search-term
result nodes, which are not queueable as a whole but whose track results
are downloadable); children mirror `is_queable`. The `/queues` and
`/bookmarks` instances opt in wholesale
(`fsdy::Client::with_downloadable_nodes`): their entries are links into
downloadable providers, so `W` on a saved queue or bookmark downloads
its resolvable tracks and skips the rest (D3). `/fs` and `/captures`
stay `false` — capturing a capture is pointless, and local trees have
nothing to download.
Tracks carry no flag: a listed track inherits its containing node's
`is_downloadable` (TUI) — a Tidal album's tracks are downloadable because
the album is. The server enforces at the capture **root**: a directory
source must report `is_downloadable`, a track source's parent node must
(`CaptureError::Unsupported` otherwise). Nested nodes inside the walk are
not re-checked — the pressed node's decision governs its subtree.
### D5 — Wire: the existing rpc gains a `download` flag
`CaptureLibraryNodeRequest` gets `bool download = 3` (additive; old
clients keep bookmarking). `ProviderCommand::CaptureLibraryNode` carries
it and the handler picks the store. Error mapping extends the bookmark
contract: `InvalidName`/`BadSource` → `invalid_argument`,
`TooLarge`/`Disabled`/`Unsupported` → `failed_precondition`, download and
disk failures → `internal`.
### D6 — TUI: `W` on the library pane
`Action::LibraryDownloadNode` bound to `W` in `Scope::Library` ("Download
selection as capture"). Gate: the bare selection must be queueable **and**
downloadable (`selected_downloadable()`; child flag for nodes, the current
node's flag for tracks; marks ignored like `w`). The existing input
overlay opens with `InputPurpose::Capture { path, download: true }`
(label `capture`), prefilled with the selection title. Submit sends
`MessageFromUi::CaptureNode { path, name, download }` → the rpc. Failures
are logged, never fatal to the poll loop.
### D7 — Out of scope (explicitly)
- Retry/resume of failed or partial downloads (re-run the capture).
- Quality/codec selection, transcoding, tagging the audio files.
- Progress display in the TUI while a capture downloads.
- Deduplicating audio across captures, or refreshing links in existing
bookmarks into downloads.
- DRM circumvention: the download uses exactly the stream URLs the
provider already serves for playback.
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
cap: "capture.rs\nshared walk + caps + swap" {
link: "Sink::Link"
dl: "Sink::Download\n(reqwest, timeouts, byte budget)"
}
bs: BookmarkStore
cs: CaptureStore
}
tidal: "tidaldy\n(is_downloadable = is_queable)"
disk: "config/crabidy" {
shape: cylinder
b: "bookmarks/<name>/ (link tomls)"
c: "captures/<name>/ (audio + file tomls)"
}
cfs: "fsdy /captures\n(editable top level)"
server.orch -> server.bs: "CaptureLibraryNode\ndownload=false"
server.orch -> server.cs: "CaptureLibraryNode\ndownload=true"
server.bs -> server.cap.link
server.cs -> server.cap.dl
server.cap.dl -> tidal: "get_urls_for_track\n+ HTTP GET stream"
server.bs -> disk.b
server.cs -> disk.c
cfs -> disk.c: "list + parse (read only)"
server.orch -> cfs: "/captures/..."
```
## Key flow: W on a Tidal album
```d2
shape: sequence_diagram
tui: TUI
rpc: gRPC
orch: Orchestrator
cs: CaptureStore
tidal: Tidal
tui -> rpc: "CaptureLibraryNode(path, name, download=true)"
rpc -> orch: "ProviderCommand (spawned)"
orch -> cs: "capture(name)"
cs -> orch: "get_lib_node: root allows download?"
cs -> tidal: "per track: get_urls_for_track"
cs -> tidal: "HTTP GET (deadline, byte budget)"
cs -> cs: "audio + toml pair\n(toml after audio, file = relative)"
cs -> rpc: "tmp-and-swap captures/<name>/"
rpc -> tui: OK
```
## Risks and open questions
- **Disk usage**: 500 tracks of FLAC can be tens of GiB; the byte budget
caps one capture, not the folder's total. Accepted — the user manages
`captures/` like any local music folder (and can delete via `d`).
- **Stream URL churn**: Tidal URLs are short-lived; the download happens
immediately after fetching each URL, so expiry only matters for very
slow transfers, which the per-track deadline already bounds.
- **Licensing**: captures are personal-use copies of streams the account
can already play; nothing here bypasses provider protection.
- Open (future): a progress event stream for long captures; retry with
classification + jitter; per-provider download quality knobs.

View File

@ -1,105 +0,0 @@
# cbd: bundled server + client binary
## Context and problem statement
`crabidy-server` and `cbd-tui` are separate binaries: the normal setup
runs a long-lived server and attaches TUIs to it. The user wants a
single binary **`cbd`** for the one-machine case: starting it starts the
server and connects the TUI to it. Everything else works exactly the
same — same config *format*, same gRPC wire, same features. (`cbd` reads
its own `cbd.toml`, not `cbd-tui.toml`; see the resolved note under
Risks.)
## Assumptions (confirmed)
- `cbd-tui`'s config (`cbd-tui.toml`) already carries the server
address; the server listens on a constant (`0.0.0.0:50051`).
- Both mains are thin shells over module code: the server's `main.rs`
holds the command/message enums and the startup sequence; the TUI's
holds tracing setup and two loops (`orchestrate`, `run_ui`).
- The gRPC boundary stays: the bundled TUI talks to the in-process
server over localhost exactly like a remote one ("works the same
completely"). No in-process transport special-casing.
## Decisions
### D1 — Both binaries become libraries with thin mains
- **crabidy-server**: `playback`, `provider`, `rpc` move from bin
modules to lib modules; the command/message enums and the startup
sequence move into the lib (`serve(addr)` builds orchestrator, queue
store, playback, rpc service and serves tonic on `addr`). `main.rs`
keeps only stderr tracing setup + `serve(LISTEN_ADDR)`.
- **cbd-tui**: gains `src/lib.rs` exposing `run(config)` (the two loops
and their channels); `main.rs` keeps file-based tracing setup +
config init + `run`.
- Behavior-preserving: no logic changes, only module moves and
`crabidy_server::``crate::` path rewrites. All existing tests move
along unchanged.
Not chosen: `cbd` spawning `crabidy-server` as a subprocess — that
needs the second binary installed, which is exactly what "a single
binary" is for.
### D2 — `cbd` = start (or adopt) the server, then run the TUI
New tiny binary crate `cbd`:
1. Tracing goes to the TUI's log file for **both** halves — the
terminal belongs to the TUI, so the server's stderr logging would
corrupt it.
2. Spawn `crabidy_server::serve(LISTEN_ADDR)` on a background task.
If the port is already taken (`AddrInUse` — a standalone server is
running), log and carry on: the TUI simply connects to the existing
server. Any other server error before readiness is fatal.
3. Wait for readiness by polling a TCP connect against the configured
server address (bounded retries with delay, then a clear error).
4. Run the TUI exactly as `cbd-tui` would, with the same
`cbd-tui.toml`.
Quitting the TUI ends the process — and with it the in-process server.
That is inherent to bundling and fine: the current queue is persisted
continuously, so the next start restores it (the ≤200 ms persist
debounce window is the same loss window as killing the standalone
server).
### D3 — Out of scope (explicitly)
- An in-process (channel) transport instead of localhost gRPC.
- Daemonizing: `cbd` never outlives its TUI. Users who want a
persistent server keep running `crabidy-server`.
- CLI subcommands (`cbd server`, `cbd attach`, …) — later if wanted.
## Structure
```d2
direction: right
cbd: "cbd (one binary)" {
boot: "main: file tracing,\nspawn server, wait, run TUI"
srv: "crabidy-server lib\nserve(addr)"
tui: "cbd-tui lib\nrun(config)"
boot -> srv: "tokio::spawn\n(AddrInUse → adopt)"
boot -> tui: "after TCP readiness"
tui -> srv: "localhost gRPC\n(unchanged wire)"
}
standalone: "crabidy-server bin\n(unchanged)"
remote: "cbd-tui bin\n(unchanged)"
remote -> standalone: "gRPC (remote setup\nkeeps working)"
```
## Risks and open questions
- **Separate client configs (resolved 2026-07-21)**: originally `cbd`
and `cbd-tui` both read `cbd-tui.toml`, so pointing that file at a
remote server (the standalone `cbd-tui`'s job) also dragged `cbd`'s
own TUI to the remote while it started an unused local server. `cbd`
now reads its own `cbd.toml` (same option set, defaulting to
localhost — matching its in-process server), so the self-contained
`cbd` and a remote-pointed `cbd-tui` coexist on one machine. See
`architecture/client-configs.md`.
- **Two log producers, one file**: server and TUI layers share the
bundled tracing subscriber; targets distinguish them.
- Open (future): a `--no-server` flag; graceful server shutdown (flush
the persister) on TUI exit.

View File

@ -1,238 +0,0 @@
# A comprehensive CLI for every binary
## Context and problem statement
Today the command line is thin and inconsistent:
- `crabidy-server` uses clap-derive but exposes only `hash-password` and no
flags (it always binds the fixed `LISTEN_ADDR`).
- `cbd-tui` and `cbd` share a ClapSerde `Config` (`-a/-u/-p`, `--spectrum`)
that doubles as the TOML schema; neither has subcommands.
- Every user operation is reachable *only* through the interactive TUI. There
is no way to script the player, set up auth, or index a music folder from the
shell.
The goal: **every binary is a clap-derive CLI with `--help`; every operation
the TUI can do is also a subcommand; there are shell completions and man pages;
and running a binary with no subcommand behaves exactly as today** (TUI, run the
server, or `cbd`'s server+TUI).
New capabilities requested:
- **`guard`** (server): hash a role password and, by default, write it into
`crabidy-server.toml`'s `[auth]`.
- **`scan`** (server): walk a path, drop a `.cbd-track.toml` beside every
playable file; `--capture` copies the audio into the content store (toml
points there), `--move` moves it instead of copying.
- **`auth`** (client): write a role + cleartext password into the client config.
- **`library` / `queue` / `global`** (server *and* client): the full set of
library, queue, and playback operations, run against a server over gRPC.
## Assumptions
- The `library`/`queue`/`global` commands are **remote**: they connect to a
running server at `--address` (default localhost) with the same basic-auth as
the TUI, reusing the generated gRPC client. A "server" binary running them is
just acting as a client to whatever server is up — including its own.
- One config file per client binary stays the source of truth for connection
defaults (`cbd-tui.toml`, `cbd.toml`); flags override it.
- Passwords may be given as an argument *or* on stdin. Argv is visible in the
process list — the help text says so, and omitting the argument reads stdin
(pipe-friendly, the current `hash-password` behavior).
- No tag extraction in `scan` v1 (title = file stem); no machine-readable
output format v1 (human-readable text). Both are noted as future work.
## D1 — A shared `cbd-cli` crate
A new library crate **`cbd-cli`** holds the clap definitions so all three
binaries share one command surface and each binary's `build.rs` can generate
assets from it. It is feature-split to keep `build.rs` light:
- **default features (clap only)**: the `Parser`/`Subcommand` types
(`LibraryCmd`, `QueueCmd`, `GlobalCmd`, `GuardCmd`, `ScanArgs`, `AuthCmd`, the
per-binary top-level `ServerCli`/`TuiCli`/`CbdCli`), a `Role` enum, a
`RemoteArgs` flatten (`--address/--user/--password`), and
`generate_assets(cmd, out_dir)` (wrapping `clap_complete` + `clap_mangen`).
Depends only on `clap`, `clap_complete`, `clap_mangen`.
- **feature `client`**: `run_remote(remote: &RemoteArgs, cmd: RemoteCmd)` — the
executor that connects a `crabidy_core` gRPC client (with a standalone
basic-auth interceptor) and runs a `library`/`queue`/`global` subcommand,
printing results. Adds `tonic`, `crabidy-core`, `tokio`.
`guard`/`scan`/`auth` *definitions* live in `cbd-cli` (so completions and man
pages cover them) but their *execution* lives in the owning binary, which has
the server config / store / client config internals `cbd-cli` must not depend
on.
```d2
direction: right
cbd_cli: cbd-cli (clap defs + asset gen) {
defs: "Parser/Subcommand types\nRemoteArgs, Role\ngenerate_assets()"
client: "feature=client:\nrun_remote() gRPC executor"
}
server: crabidy-server {
guard_scan: "guard + scan\n(config writer, store)"
}
tui: cbd-tui {
auth: "auth (client config writer)"
}
cbd: cbd (bundle)
core: crabidy-core (generated client)
cbd_cli.defs -> server.guard_scan: defines
cbd_cli.defs -> tui.auth: defines
cbd_cli.client -> core: gRPC to a running server
server -> cbd_cli.client: library/queue/global
tui -> cbd_cli.client: library/queue/global
cbd -> server: reuses guard/scan
cbd -> tui: reuses auth
cbd -> cbd_cli.client: library/queue/global
```
## D2 — Per-binary CLI and the no-subcommand default
Each binary defines a top-level clap `Parser` (in `cbd-cli`) with global
connection flags and an **optional** subcommand:
- `ServerCli`: `[guard|scan|library|queue|global|completions]`; none → run the
server (as today).
- `TuiCli`: `RemoteArgs` + `--spectrum` + `[auth|library|queue|global|
completions]`; none → run the TUI.
- `CbdCli`: the union — `[guard|scan|auth|library|queue|global|completions]`;
none → server + TUI (as today). This is "cbd has the commands from both."
**Config merge.** The no-subcommand path must keep today's behavior: read the
TOML (writing defaults on first run) and let flags override. ClapSerde did this
implicitly; a top-level `Parser` with a subcommand does not compose cleanly with
ClapSerde's `Opt`. Decision: replace ClapSerde for the client binaries with a
plain `serde` config load plus an explicit override step — the `RemoteArgs`
fields are `Option`, and a provided flag overrides the file value. `init_config`
keeps writing a defaults file on first run. This is a small, well-contained
change to `cbd-tui/src/config.rs` and the two `main.rs` files.
Alternative considered: sniff argv for a known subcommand before ClapSerde
parsing and branch. Rejected — it forfeits a unified `--help`/completions and is
fragile.
## D3 — `library` / `queue` / `global` (remote)
The executor (`cbd-cli` feature `client`) maps subcommands to the existing RPCs
(`RpcClient` already wraps all but `Stop`, which gains a wrapper):
- `library list [PATH]` (default `/`) → `GetLibraryNode`; prints child nodes and
tracks (path, title; captured rows marked, per architecture/crabidy-store.md).
- `library create <PARENT> <TITLE>`, `rename <PATH> <TITLE>`,
`delete <PATH>` → the matching library RPCs.
- `library save <PATH> <NAME>``CaptureLibraryNode{download:false}`;
`library capture <PATH> <NAME>``{download:true}`.
- `queue show``Queue`; `queue append|insert|replace <PATH>…`,
`queue remove <POS>…`, `queue clear [--keep-current]`,
`queue set-current <POS>`, `queue save <NAME>`, `queue capture <NAME>`
(→ `CaptureLibraryNode` on `/crabidy/current`), `queue shuffle`,
`queue repeat` (the `QueueModifiers` toggles).
- `global play|stop|next|prev|restart|mute`, `global volume <DELTA>` (or
`up|down` sugar around `ChangeVolume`).
Connection: `RemoteArgs` → a lazily-connected `CrabidyServiceClient` with a
basic-auth interceptor built from `--user/--password` (empty user = no header,
i.e. an open server). Each command is a one-shot: connect, call, print, exit.
`RpcClient::connect`'s `&'static ServerConfig` signature is loosened (or the
executor builds the client directly) so a CLI can pass an owned config.
Output is human-readable text; a `--json` flag is future work (D9).
## D4 — `guard` (server)
`crabidy-server guard <ROLE> [PASSWORD] [--no-config]` where `ROLE`
`owner|queue-owner|appender`:
1. Read the password from the argument, or from stdin if omitted.
2. Hash it (`auth::hash_password`, argon2id) and **print the PHC string** to
stdout (so it stays pipeable and `--no-config` reproduces today's
`hash-password`).
3. Unless `--no-config`: load `crabidy-server.toml` (or defaults), set the role's
field (`owner`/`queue_owner`/`queue_appender`) to the hash, and write it back,
preserving the flat `[auth]` shape and the other roles. This needs a **config
writer** (D8) — none exists today.
`hash-password` is removed in favor of `guard` (`guard owner <pw> --no-config`
is the exact replacement). The role names match the basic-auth user names.
## D5 — `scan` (server)
`crabidy-server scan <PATH> [--capture] [--move]`:
- Walk `<PATH>` recursively (bounded, skipping hidden entries), selecting files
by audio extension (flac/mp3/m4a/ogg/opus/wav/webm/aac…).
- For each audio file, write a `<stem>.cbd-track.toml` **beside it** using
`fsdy::TrackFile` (`to_toml`, the shared naming), so the folder becomes a
browsable `/fs` tree. Title defaults to the file stem (tag extraction is
future work).
- Default (neither flag): the toml's playable is `Playable::File` pointing at
the audio's own file name (relative) — the audio stays where it is.
- `--capture`: ingest the audio into the content store (hash + de-dup + sidecar,
reusing `CrabidyStore`), and the beside-file toml gets a `Playable::Store`
entry instead. Idempotent: an already-stored file de-dups.
- `--move`: like `--capture` but the source audio is moved into the store, not
copied — the original location keeps only the toml.
- `--capture`/`--move` require the content store (a state/data dir);
`scan` opens a `CrabidyStore` directly and calls a new `ingest_file(path,
move) -> StoreName` helper (the local-source half of the D4 capture flow,
factored out).
Existing `.cbd-track.toml` files are left untouched (scan never clobbers a
hand-edited toml); a warning notes skips.
## D6 — `auth` (client)
`cbd-tui auth <ROLE> [PASSWORD] [--address ADDR]` (and the same on `cbd`):
loads the client config (`cbd-tui.toml` / `cbd.toml`), sets `user` to the role
name and `password` to the cleartext (optionally `address`), and writes it back
via the **client config writer** (D8). The file is chmod-private-friendly; the
help text repeats that the client config holds a plaintext password.
## D7 — Shell completions and man pages
Add `clap_complete` and `clap_mangen`. Each binary's `build.rs` build-depends on
`cbd-cli` (default features — clap only, cheap) and, from its top-level
`Command`, writes bash/zsh/fish completions and a `man` page into `OUT_DIR`
every build (a genuine build step). When `CBD_ASSET_DIR` is set, `build.rs` also
copies them into that stable directory. A devenv `gen-cli-assets` script sets
`CBD_ASSET_DIR=$PWD/dist` and builds, so `dist/completions/**` and `dist/man/*.1`
are produced on demand. A hidden `completions <shell>` subcommand on each binary
prints a completion script to stdout for ad-hoc use.
Alternative considered: an `xtask` generator binary. Rejected — a `build.rs`
keeps generation automatic and in lockstep with the CLI definition; the
`CBD_ASSET_DIR` copy covers the "get them into the repo" need.
## D8 — Config writers (new)
Two small, careful serializers, both load-modify-write preserving shape:
- **Server** (`crabidy-server`): `ServerSettings` gains a `store(config_dir)`
that serializes the current `[auth]` (round-tripping the existing file so
unknown-field rejection stays satisfiable) — used by `guard`.
- **Client** (`cbd-tui`): the `Config`/`ServerConfig` is already `Serialize`;
`auth` loads it, sets the fields, and writes `toml::to_string_pretty` back to
the config path — used by `auth`.
Both write to `dirs::config_dir()/crabidy/<file>` and create it if missing.
## D9 — Out of scope / future
- Tag extraction in `scan` (a `lofty`-backed title/artist/album/duration).
- `--json` machine-readable output for `library list` / `queue show`.
- Watching/streaming (`global watch` over `GetUpdateStream`).
- Bulk auth (multiple roles in one `guard` invocation).
## Risks
- **Password in argv** is visible process-wide; mitigated by the stdin fallback
and documented in help. Accepted per the explicit request.
- **ClapSerde → clap migration** for the client config changes the parse path;
the first-run-writes-defaults and flag-overrides-file behaviors must be
preserved by tests.
- **`build.rs` writing outside `OUT_DIR`** (the `CBD_ASSET_DIR` copy) is
unconventional; gated on the env var so ordinary builds only touch `OUT_DIR`.
- **`cbd-cli` as a build-dependency** must stay clap-only by default, or every
build drags in tonic — enforced by the feature split.

View File

@ -1,51 +0,0 @@
# Client configs: `cbd.toml` vs `cbd-tui.toml`
## Problem
`cbd` (server + TUI in one process) and `cbd-tui` (standalone terminal
client) both originally loaded `cbd-tui.toml`. On a single machine the
common setup is:
- `cbd` — self-contained, playing on this laptop against its own
in-process server (localhost);
- `cbd-tui` — a remote control pointed at another server (e.g. a
Raspberry Pi).
With one shared config file these two uses fight over a single
`address`: point it at the Pi for `cbd-tui`, and `cbd`'s TUI half also
connects to the Pi while its local server runs unused.
## Decision
Give each binary its own client config file, with the **same option
set** (`address`, `user`, `password` — the `ServerConfig` type is
shared):
- `cbd` reads `cbd.toml`.
- `cbd-tui` reads `cbd-tui.toml`.
Both default `address` to `http://127.0.0.1:50051`. For `cbd` that
default is the right permanent value — it matches the in-process
server's listen address — so `cbd` needs no config at all. `cbd-tui`'s
default is a starting point the user overrides to point at a remote
server. The files being separate is the whole point: editing one never
moves the other.
The server-side `crabidy-server.toml` (the `[auth]` roles) is unrelated
and unchanged; this split is purely about the client `address`/creds.
## Alternatives considered
- **One file, add a `cbd`-only override section**: still one file to
reason about, and the override semantics (which wins?) are murkier
than two files with identical shape. Rejected.
- **A CLI flag only for `cbd`**: does not persist; the user wants a
laptop that "just works" on every start. Rejected (the flag still
exists as an override, as for `cbd-tui`).
## Migration
Existing users keep their `cbd-tui.toml` for `cbd-tui`. On first run
after the change, `cbd` writes a fresh `cbd.toml` with localhost
defaults; anyone who had customized `cbd-tui.toml` *for `cbd`'s* sake
(unusual — the default already fit) copies the value across once.

View File

@ -1,347 +0,0 @@
# The `crabidy` provider and the content-addressed store
## Context and problem statement
Today the server exposes three hand-managed subtrees — `/queues`, `/bookmarks`,
`/captures` — each a separate `fsdy::Client` instance rooted under
`~/.config/crabidy/`. Bookmarks (`w`) write *link* tomls; captures (`W`)
download audio next to each toml (`capture-deletion.md`,
`incremental-captures.md`). This has three problems the user wants fixed
green-field (no data migration):
1. **No de-duplication.** Capturing the same track from two places (a playlist
and a search, or two saved queues) downloads and stores the audio twice.
Re-capturing after a reorder re-downloads everything.
2. **Audio lives next to metadata.** Deleting a capture folder must carefully
remove downloaded audio from disk behind a confirmation
(`capture-deletion.md`), because the audio is only referenced from that one
folder. This couples deletion to expensive-payload bookkeeping.
3. **Three UI concepts** (queues, bookmarks, captures) for what the user thinks
of as "my crabidy stuff." They want one provider.
This design replaces all three with **one filesystem provider, `/crabidy`**,
whose track tomls *link into a single content-addressed store* of playable
files. Captures de-duplicate by provider identity and by content hash; deletion
becomes a plain toml removal that never touches the store.
This **supersedes** `bookmarks.md`, `captures.md`, `capture-deletion.md`, and
the on-disk/resumption parts of `incremental-captures.md` (the skipped-track and
progress-stream parts of that doc survive; see D9).
## Assumptions (confirmed by the request)
- **Green-field.** No migration of existing `~/.config/crabidy/{queues,
bookmarks,captures}` data. On first run the new locations are simply empty.
- **Two roots, split by XDG kind.** The store (playable audio + its sidecars)
is *data*`~/.local/share/crabidy/` (`dirs::data_dir()`). The tomls (the
`/crabidy` tree) are *state*`~/.local/state/crabidy/` (`dirs::state_dir()`).
- **Single server, single writer.** One process owns both roots; capture
mutations are serialized. No cross-host concurrent writers.
- **The store never shrinks automatically.** Deleting a toml never deletes store
audio (D7). Orphaned store entries are accepted; a garbage collector is future
work (D10).
- **`current` stays special.** The live queue is still mirrored to a reserved,
user-untouchable folder — now `/crabidy/current`, flat as before.
## D1 — One provider: `/crabidy`
`ProviderOrchestrator` drops the `queues`/`bookmarks`/`captures` fields and gains
one `crabidy` field: an `fsdy::Client` rooted at `~/.local/state/crabidy/`,
mounted at `/crabidy`, built `with_editable_top_level(&["current"])` (top-level
saves are renamable/deletable; `current` is reserved) `.with_downloadable_nodes()`
`.with_deletable_tree()` (every node deletes directly — see D7). A companion
writer, `CrabidyStore`, owns *both* roots and all mutation.
- Top-level folders under `/crabidy` are **user saves**, each created by `w`/`W`.
- **Saved queues are flat**; **saved library subtrees preserve their structure**
(falls out of walking the source — a flat queue yields a flat save).
- `/crabidy/current` is the live-queue mirror the playback loop keeps in sync
(replaces `/queues/current`). It is flat and reserved.
Non-toml files in a save folder (e.g. client log files that also land in
`~/.local/state/crabidy/`) are ignored by listing, as today — the provider only
surfaces subdirectories and `*.cbd-track.toml` files. (Optional tidy-up, not
required: move client logs to `~/.local/state/crabidy/logs/`.)
```d2
direction: right
orchestrator: ProviderOrchestrator (root, path-routed) {
tidal
youtube
fs
crabidy: /crabidy (fsdy::Client, read + delete)
}
state: "~/.local/state/crabidy/\n(toml tree)" { shape: cylinder }
share: {
label: "~/.local/share/crabidy/\ncontent store: audio + sidecars"
shape: cylinder
}
store_writer: CrabidyStore (single writer, owns both roots) {
index: "StoreIndex\n(provider-id → entry,\nhash → entry)"
}
orchestrator.crabidy -> state: lists tomls
orchestrator.crabidy -> share: resolves Store playables
store_writer -> state: writes save folders / track tomls
store_writer -> share: writes audio + sidecars
store_writer.index -> share: derived by scanning sidecars at open
```
## D2 — The store and its sidecars
`~/.local/share/crabidy/` is a **flat** directory. Each unique playable is a
pair:
- `<name>` — the audio file, named after the source's natural name (a local
file's basename, else a sanitized `<title>.<ext>` with the extension from the
download's `Content-Type`/URL). On name collision with *different* content,
append a numeral: `song.flac`, `song (2).flac`, … (identical content never
reaches naming — it de-dupes first, D4).
- `<name>.cbd-store.toml` — the sidecar, the store entry's metadata:
```toml
# song.flac.cbd-store.toml
hash = "blake3:1f0c…" # content hash of the audio file
[[provider]] # one entry per provider-id that maps here
provider = "tidal" # provider name (path root of the source)
id = "125169484" # provider-internal id (see D3)
title = "Bohemian Rhapsody"
artist = "Queen"
duration = 355
aliases = ["Bohemian Rhapsody (Remastered)"] # other titles for this id
[provider.album]
title = "A Night at the Opera"
release_date = "1975-11-21"
[[provider]] # same content reached via a second identity
provider = "youtube"
id = "fJ9rUzIMcZQ"
title = "Queen Bohemian Rhapsody (Official Video)"
```
The **sidecars are the single source of truth** — there is no separate persisted
index file. `CrabidyStore` builds an in-memory `StoreIndex` by scanning
`*.cbd-store.toml` at open and updates it on every write:
- `by_provider_id: HashMap<(provider, id), StoreRef>`
- `by_hash: HashMap<Hash, StoreRef>`
A `StoreRef` is the store `<name>` (which is the toml link target and the sidecar
key). Lookups are O(1).
> **"grep without shelling out."** The request describes searching sidecars
> for a provider id / hash like ripgrep, in-process. The `StoreIndex` *is* it,
> memoized: it is derived by reading the sidecars in-process at open (no shell,
> no external index), so the store stays self-describing. A live
> content-scan-then-parse (via the `grep-searcher` crate) was considered as the
> literal realization; rejected because an in-memory map built once is simpler,
> strictly faster for repeated captures in a session, and needs no new
> dependency. If the store ever grows beyond memory, a lazy content-scan is the
> fallback (D10).
## D3 — Provider identity on the wire
The store keys on a **provider-internal id** — "the id that clearly identifies
the item inside the provider," which the path cannot be (the same item is
reachable via a playlist, a search, an album…). Today no such id exists; identity
is the path string. We add it to the wire model:
- `Track.provider_item_id` (proto field 7, `string`) — set by the owning
provider when it produces a `Track`. Empty when unknown.
- **tidal** → the numeric track id.
- **youtube** → the video id.
- **fs** → the source file's canonical absolute path (two `/fs` tomls pointing
at the same file share an id → they de-dupe).
- **crabidy** (already store-backed) → the store `<name>`; used only to detect
"already captured" (D5).
- The **provider name** is the first path segment of the (resolved) source track
path — `to_track` already rewrites a link track's `path` to its target, so a
queued link routes to the real provider and carries that provider's id.
For the capture indicator (D8) we also add:
- `Track.is_captured` (field 8, `bool`) — the server sets it at listing time when
the store index has an entry for this track's `(provider, id)` **or** its
playable is already a store link. Cheap (one hashmap lookup) and works while
browsing *any* provider, so you can see what you have captured.
- `LibraryNode.is_captured` (field 10) and `LibraryNodeChild.is_captured`
(field 8) — a node is captured iff all its tracks and child nodes are captured
(D8 covers how the `/crabidy` provider computes this cheaply).
## D4 — Capturing one track: the de-dup flow
`store = true` means the track links into the store; `link` means a bookmark link
to the source provider. Capture (`W`) produces store links; bookmark (`w`)
produces plain links and never touches the store.
For each source track a capture walk visits, in order:
```d2
direction: down
start: "resolve source track\n(provider, id, natural name)"
already: "already store-backed?\n(Store playable, or fs file under the store root)"
byid: "index.by_provider_id[(provider,id)] ?"
getbytes: "obtain bytes\n(download → temp, or local file)"
byhash: "index.by_hash[hash(bytes)] ?"
newentry: "NEW: copy into store\n(+numeral) + write sidecar"
addid: "add provider to sidecar,\ndiscard the temp copy"
writetoml: "write track toml with playable.store = <name>"
start -> already
already -> writetoml: "yes → reuse target store name (no-op copy)"
already -> byid: "no"
byid -> writetoml: "HIT → reuse; record alias if title differs"
byid -> getbytes: "MISS"
getbytes -> byhash
byhash -> addid: "HIT (same content, new identity)"
byhash -> newentry: "MISS"
addid -> writetoml
newentry -> writetoml
```
1. **Already store-backed?** If the source track's playable is a `Store` link, or
it is an `/fs` file whose path is already under the store root, there is
nothing to fetch — the save's toml links to the same store `<name>`. (This is
the "capture on an already-captured fs item → do nothing" case.)
2. **Provider-id lookup.** `index.by_provider_id[(provider, id)]` — a hit means
we already have this exact provider item. Point the save's toml at that store
entry. If the current title differs from the stored one, append it to that
entry's `aliases`. **No download.** (Common re-capture path — makes redoing a
save cheap.)
3. **Miss → obtain bytes.** Streamed source: windowed HTTP download to a temp
file (unchanged mechanics). Local source (`/fs` pointing at a normal file, not
under the store): the file is the bytes. Hash the bytes (blake3).
4. **Hash lookup.** `index.by_hash[hash]` — a hit means identical content is
already stored under some other identity. **Add** a `[[provider]]` entry to
that sidecar, **discard** the temp download (or do not copy the fs file),
point the toml at the existing store name. No duplicate.
5. **Miss → new store entry.** Choose a store name from the natural name
(+numeral on collision), move the temp file (or copy the fs file) into the
store, write the sidecar with `hash` and the first `[[provider]]` entry, update
the index. The `/fs` case *copies* — the original music-folder file stays put.
The byte budget (`DOWNLOAD_CAPS.max_bytes`) still counts bytes fetched *this run*
(hits cost nothing), so dedup makes big saves cheaper, never starves them.
## D5 — Save (`w`/`W`) and conflict handling
A **save** takes a *source* (a live-queue snapshot or a library-node path) and a
*mode* (`Link` for `w`, `Capture` for `W`) and writes a new top-level folder
`/crabidy/<name>`:
- **`w`** on a library node or the queue → folder of **link** tomls
(`fsdy::TrackFile::from_track`), the bookmark semantics, no store, no audio.
- **`W`** on a library node or the queue → `w` **plus** run the D4 capture per
track; tomls carry `playable.store`. Works on both library nodes and the queue.
**Conflict handling.** Each save records its origin in a hidden
`.cbd-save.toml` marker at the save root (`source = "<captured node path>"`,
`capture = true|false`). When `/crabidy/<name>` already exists:
- **`w` (link save)** always refuses — do nothing and warn `name "<name>"
already exists`; the user deletes the old folder and saves again.
- **`W` (capture)** refuses **unless it is a re-capture of the same source**:
if the existing save's marker `source` equals the node being captured, the
save **replaces** it; a different source still refuses. So pressing `W`
again on the same item (or `W` on the queue again) refreshes the capture in
place, while `W` naming a *different* item after an existing save is still
protected.
The save is built in a hidden `.tmp-<name>` sibling and swapped in atomically;
a permitted replace removes the old folder and renames the temp over it. A
crashed/failed run leaves **no** blocking partial folder, and any audio already
committed to the store persists and makes a retry fast via D4 (resumability
lives in the store, not the folder). Replacing is cheap and safe because a
save folder holds only tomls — the shared store audio is never rewritten or
deleted (D7). A save that predates the marker (no `.cbd-save.toml`) is treated
as a different source and refuses; delete it once to re-establish it.
`current` is exempt: the playback loop overwrites it on every queue change; the
user cannot save over the reserved name `current`.
## D6 — RPC surface
- **`CaptureLibraryNode(path, name, download)`** stays and now covers *all four*
gestures: `w`/`W` on a library node (`path = /tidal/...`), and `w`/`W` on the
queue (`path = /crabidy/current`). `download=false``Link`, `true`
`Capture`. Validation errors (bad name, conflict, source not downloadable)
return synchronously; progress streams via the existing `CaptureProgress`
update (unchanged, D9).
- **`SaveQueue(name)` is retained** (this deviates from the original plan to
remove it — see `plan/summary.md`). It is the queue `w` gesture and is
reimplemented server-side as a *link* save of the live queue into
`/crabidy/<name>` (via `CrabidyStore::save_snapshot`). Queue `W`
(`QueueDownloadCapture`) goes through `CaptureLibraryNode` on
`/crabidy/current` with `download = true`.
- `DeleteLibraryNode(path)` unchanged in shape; behavior simplified (D7).
## D7 — Deletion
Deletion on `/crabidy` (the only writable fs provider) **goes through directly,
no confirmation, and never touches the store**:
- Delete a track → remove its `.cbd-track.toml` only.
- Delete a folder → `remove_dir_all` of the toml folder only.
The existing `fsdy::delete_track_file` guard — "only delete the referenced audio
if it is contained under the instance root" — already makes this safe: a
`Store` playable resolves under `~/.local/share/…`, which is *outside* the
`/crabidy` toml root at `~/.local/state/…`, so the audio is never deleted. And
`w`-saves are links with no audio at all.
Consequences: drop the TUI's `delete_needs_confirmation` / `ConfirmDelete` path
(the whole `capture-deletion.md` confirmation feature is gone — nothing expensive
is destroyed anymore) and drop `with_deletable_tree`'s audio-removal branch usage
for this provider (folder/toml removal remains).
## D8 — UI: collapse to one provider, mark captured nodes
- `/queues`, `/bookmarks`, `/captures` disappear from the root; one `/crabidy`
child appears (title `crabidy`). Inside it, `w`-saves (links) and `W`-saves
(store-backed) coexist, distinguished by the captured marker.
- **Captured marker:** a captured row carries a trailing `↓` at the *end of
the row* — a status marker after the action-key brackets (outside them),
e.g. `Bohemian… ↓`.
- A **track** row is captured per `Track.is_captured` (D3): store-backed, or its
`(provider, id)` is in the store index — visible even while browsing tidal.
- A **node/child** row is captured iff all its tracks and child nodes are
captured. The `/crabidy` provider computes a node's own `is_captured` when it
lists it (it reads every track toml anyway → all `Store`?). To mark child
*folders* in a parent listing without deep recursion, a save records its mode
in a one-line marker at the save root written by `w`/`W`; nested folders under
a `W`-save are captured by construction. Exact recursion depth is an
implementation detail (see plan) — the invariant is "captured = fully local."
- The existing capture **progress** lines (`capturing <name> 12/34 …`) stay as-is.
## D9 — What carries over from `incremental-captures.md`
Kept: the `Skipped` playable (D1 there), skipped tracks in the queue and playback
skipping them (D3), the `CaptureProgress` stream and accept-then-stream RPC (D4),
and the focused-selection contrast fix (D7). A source that genuinely cannot be
fetched still writes a skipped toml. What changes: the *store write mode* (audio
now goes to the shared store, tomls carry `store`, dedup per D4) and *resume
semantics* (folder is atomic; store provides the savings, per D5).
## D10 — Out of scope / future
- **Store garbage collection.** Nothing reclaims store entries whose last
referencing toml was deleted. A future GC would scan all `/crabidy` tomls for
live `store` names and remove unreferenced pairs; needs its own design.
- **Lazy content-scan** instead of the in-memory index, for stores too large to
index in memory (D2).
- **Cross-provider captured marking of folders** (e.g. a tidal album shown
captured) beyond the cheap track-level lookup.
- **Cancelling a running capture** (unchanged from prior scope).
## Risks
- **Wasted download on a hash-only match** (provider-id missed but content is
identical): we download, then discard. Unavoidable for content dedup; the
provider-id path avoids it in the common case.
- **Natural-name collisions** across unrelated tracks are handled by the numeral
suffix; the store name is opaque to users (only the toml title shows in UI).
- **Index/disk skew** if something outside the server edits the store: the index
is rebuilt at every start, and the server is the sole writer, so skew is
bounded to a single run — acceptable.
- **blake3 dependency** added (fast, maintained, no C toolchain). Alternative
`sha2` rejected for speed; hashing whole tracks is on the capture hot path.

View File

@ -1,253 +0,0 @@
# Filesystem provider
## Context and problem statement
crabidy currently has exactly one media provider (Tidal, crate `tidaldy`)
behind the `ProviderClient` trait and the `ProviderOrchestrator` that routes
by path prefix. The user wants a second provider that walks a local
directory tree and treats files with a well-known extension as *serialized
track nodes*: small metadata files that describe a track and point at the
thing that actually plays. The playable reference can be
1. a **local audio file** (mp3/flac/… somewhere on disk),
2. a **web URL** (a stream, a radio station, a direct http(s) link), or
3. a **crabidy-internal link** (a track path owned by another provider,
e.g. `/tidal/artists/3634161/536243361`).
The request's open question — "new datastructure or our existing node?" —
is decided below (D1: existing node on the wire, a new on-disk schema for
the file).
## Assumptions (confirmed against the code)
- `audio-player` already plays both cases we need natively
(`player_engine.rs`): a source string that parses as an `http(s)` URL is
streamed via `stream-download`; anything else is opened as a **local
file path**. No player changes are required. (`file://` URLs would be
rejected — the provider must return plain paths, not file URLs.)
- `Track.path` is the routing key for playback: the queue stores whole
`Track` messages, and `GetTrackUrls`/`get_metadata_for_track` route by
the path's first segment in `ProviderOrchestrator`. Nothing in the
server assumes a track's path belongs to the provider whose node listed
it.
- The default `ProviderClient::resolve_tracks_into` walk (one chunk per
track-bearing node, pre-order) is fast enough for local disk I/O; the
page-streaming override exists for slow remote APIs.
- The TUI needs **no changes**: `/fs` appears as one more child of the
synthetic root, directories are nodes, track files are tracks.
## Decisions
### D1 — Reuse `Track`/`LibraryNode`; the only new schema is on disk
Options considered:
- *(a)* New proto message (e.g. `TrackRef` with a `oneof playable`) carried
through queue, RPCs, and TUI.
- *(b)* Reuse the existing `Track`/`LibraryNode` messages unchanged; the
"reference to a playable thing" lives only inside the fs provider's
on-disk file and is resolved to ordinary crabidy semantics at the
provider boundary.
**Decision: (b).** A new wire type would ripple through the queue, every
RPC, and both clients for zero client-visible benefit — the queue and TUI
only ever need *metadata + a playable path*, which `Track` already is. The
new datastructure is purely the **serialized track-file schema** (D3),
private to the fs provider crate.
### D2 — Internal links resolve by *path rewriting* at listing time
Options considered:
- *(a)* Keep `Track.path = /fs/...` for link tracks and add an indirection
mechanism at play time (orchestrator re-dispatches `get_urls_for_track`
when the fs provider reports a redirect).
- *(b)* When the fs provider builds a `Track` from a link file, it sets
`Track.path` to the **link target** (e.g. `/tidal/...`). The file's own
metadata still fills artist/title/album. From then on the track *is* a
tidal track as far as the queue and playback are concerned; the
orchestrator's existing prefix routing does the rest.
**Decision: (b).** Zero new mechanisms: `get_urls_for_track` and metadata
refresh route to the owning provider automatically, and a dead target
degrades exactly like any other dead tidal track (playback warn + skip).
Consequences, accepted deliberately:
- The queue shows the metadata written in the file (authoritative by the
user's own description), not the target's live metadata.
- `get_urls_for_track` on an fs path whose playable is a link cannot occur
through normal flow (the path was rewritten before it could be queued);
if it happens anyway it is `MalformedPath` with a warning, not a chain
resolution. **Links therefore resolve one hop by construction**: a link
whose target is itself a link file dies at play time with a warning, and
cycles cannot recurse. (Amended by queue-persistence D2: the original
"no links into `/fs`" parse-time rejection was dropped — persisted
queues must link to `/fs` tracks — and this one-hop argument replaces
it.)
### D3 — On-disk schema: TOML, extension `.cbd-track.toml`, exactly one playable
TOML per project convention. A file named `<anything>.cbd-track.toml` inside
the configured root is a track node; everything else (other files, hidden
entries) is ignored. Schema:
```toml
# Required.
title = "We Will Rock You"
# Optional; empty when omitted (web radio streams often have no artist).
artist = "Queen"
# Optional, seconds.
duration = 122
# Optional.
[album]
title = "News of the World"
release_date = "1977-10-28"
# Required: exactly one of `file`, `url`, `link`.
[playable]
file = "../flac/we-will-rock-you.flac"
# url = "https://example.org/stream.mp3"
# link = "/tidal/artists/3634161/536243361"
```
- `playable` is parsed as a struct of three `Option`s and validated to
**exactly one** set — this gives precise error messages, unlike an
untagged serde enum.
- `file`: absolute, or relative to the *track file's directory* (so a
music folder stays relocatable). Existence is **not** checked at listing
time (TOCTOU; the player produces a good error at play time).
- `url`: must parse as `http`/`https` (matching what the player accepts).
- `link`: must be an absolute crabidy path (`/`-prefixed). Links into fs
instances (including `/fs` itself) are legal — persisted queues rely on
it (queue-persistence D2); safety comes from links resolving one hop
only (see D2 above).
- A file that fails to parse or validate is **skipped with a warning** at
listing time; it never panics and never poisons its directory (hard
rule: no panic on user input).
### D4 — Library mapping: one configured root, encoded segments, sorted listing
- Config `~/.config/crabidy/fsdy.toml`, written back with defaults on
first run like `tidaly.toml`. Single field `root` (absolute path);
default `dirs::audio_dir()` (`~/Music`). One root keeps the path scheme
flat; multiple roots stay future work (they would need a
`/fs/<root-name>/` layer).
- Paths: `/fs/<seg>/<seg>/…` where each segment is
`encode_segment(file_name)` — the same escaping search terms use, so
arbitrary file names (spaces, `%`, unicode) survive the path scheme.
- **Traversal safety**: decoded segments are rejected if they are `.`/`..`
or contain a path separator; the joined path is a pure descent from the
root by construction.
- **Symlinks are skipped** during directory listing (`file_type()` without
follow) — no cycles, no escaping the root. A `playable.file` target may
be a symlink; that is the player's problem.
- Listing order: directories and track files each sorted
case-insensitively by file name — deterministic queueing order; users
order albums with `01`-style file name prefixes as everywhere else.
- Directories are `LibraryNodeChild { is_queable: true }`; queueing one
resolves its whole subtree via the **default** `resolve_tracks_into`
walk (one chunk per directory — local disk needs no page streaming).
Empty directories are fine: they contribute nothing.
- Fresh read on every navigation, no cache, no file watching — edits with
a file manager appear on the next visit.
### D5 — New crate `fsdy`, non-fatal init, orchestrator routing
- New workspace crate **`fsdy`** (naming symmetry with `tidaldy`),
`PROVIDER_ROOT = "/fs"`, implementing `ProviderClient`.
- `ProviderOrchestrator` gains `fs_client: Option<Arc<fsdy::Client>>` and
routes `/fs` prefixes in every trait method; `get_lib_root` adds the
`/fs` child only when the client exists. **Init failure is non-fatal**
(warn + run without `/fs`): unlike Tidal, a broken local config must not
take the whole server down, and existing installations have no
`fsdy.toml` yet. All I/O through `tokio::fs` (no blocking the runtime).
### D6 — Out of scope (explicitly)
- `create/rename/delete_lib_node`: `NotSupported`. Track files are edited
with normal file tools; a TUI editor for them is future work.
- Reading audio-file tags (ID3 etc.) to synthesize track nodes for plain
`.mp3` files sitting in the tree: future work — this feature is about
the serialized-node format.
- Multiple roots, file watching: rejected above. Link chains resolve at
most one hop (D2); deeper chains fail at play time by design.
- Since queue-persistence D1, `fsdy::Client::new(provider_root, disk_root)`
can mount additional instances (the server mounts `/queues` over the
persisted-queues folder); `fsdy.toml` still configures only `/fs`.
## Structure
```d2
direction: right
disk: Local disk {
shape: cylinder
tree: "root dir: dirs, *.cbd-track.toml"
}
server: crabidy-server {
playback: Playback loop
orch: ProviderOrchestrator {
route: "route by first path segment"
}
}
fsdy: fsdy::Client {
parse: "parse + validate .cbd-track.toml"
map: "path <-> root-relative file (encoded segments)"
}
tidaldy: tidaldy::Client
player: audio-player {
url: "http(s) -> stream-download"
file: "other -> File::open"
}
server.playback -> server.orch: "GetTrackUrls(track.path)"
server.orch -> fsdy: "/fs/..."
server.orch -> tidaldy: "/tidal/..."
fsdy -> disk.tree: tokio::fs
server.playback -> player: "play(url | file path)"
```
## Key flow: queue a directory containing all three playable kinds
```d2
shape: sequence_diagram
tui: TUI
pb: Playback loop
orch: Orchestrator
fs: fsdy
tidal: tidaldy
tui -> pb: "ReplaceQueue([/fs/mix])"
pb -> orch: ResolveTracks("/fs/mix", chunk_tx)
orch -> fs: resolve_tracks_into (spawned)
fs -> fs: "list dir, parse 3 track files"
fs -> pb: "chunk of 3 Tracks (paths below)" {style.bold: true}
pb -> orch: "GetTrackUrls(/fs/mix/a.cbd-track.toml)"
orch -> fs: get_urls_for_track
fs -> pb: "[/home/u/Music/a.flac]"
pb -> orch: "GetTrackUrls(/tidal/...) # link track, rewritten path"
orch -> tidal: get_urls_for_track
tidal -> pb: "[https://tidal-cdn/...]"
```
(The second track's `Track.path` stays `/fs/...` — its playable is a URL,
returned by `fsdy::get_urls_for_track`. Only `link` files rewrite the
path.)
## Risks and open questions
- **Malicious/odd trees**: deep nesting is bounded only by the walk's
worklist (memory-cheap); huge directories list in one node — accepted
for local disk. Traversal and symlink escapes are closed by D4.
- **Dangling references**: dead `file`/`url`/`link` targets surface at
play time as the existing "failed to open / no provider owns" warnings;
the queue keeps going. No preflight validation by design.
- **Metadata drift** on link tracks (file says X, target now titled Y):
accepted; the file is the user's curated metadata.
- Open (future): tag-reading for bare audio files; multiple roots; a
`%`-style creator that writes a `.cbd-track.toml` from inside the TUI.

View File

@ -1,216 +0,0 @@
# fyyd provider (podcasts)
## Context and problem statement
A new library provider mounted at `/fyyd` that lets a user **find and play
podcasts**, backed by [fyyd](https://fyyd.de)'s public search engine.
- **Search** works with no account — creatable search-term nodes exactly
like `/tidal/search` and `/youtube/search` (`%` creates a term, results
appear underneath).
- Unlike YouTube, a podcast search does **not** return tracks directly: it
returns *podcasts*, each of which is a container of *episodes*. So the
tree carries **one extra level**`search-term → podcast → episodes`
where YouTube is `search-term → tracks`.
- **Playing** an episode means streaming its `enclosure` URL (the plain
HTTP(S) audio file from the podcast's RSS feed). That is exactly the
URL-returning shape the audio player already handles; no sidecar, no
cipher solving, no byte-proxying (contrast `/youtube`).
- **Captures** (`W`, download) come for free: any node that serves tracks
and raises `is_downloadable` gets `w`/`W` with no wire or TUI work.
## The fyyd API (grounding)
Base `https://api.fyyd.de/0.2/`, no key or auth for search and browse.
Responses are wrapped in a JSON envelope `{ "status", "msg", "data", … }`;
list endpoints add `meta.paging`. The endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Search podcasts | `GET /search/podcast?term=<t>&count=<n>` |
| Podcast episodes | `GET /podcast/episodes?podcast_id=<id>&count=<n>` |
| Hot (featured) podcasts | `GET /feature/podcast/hot?count=<n>` |
| Single episode | `GET /episode?episode_id=<id>` |
Objects (fields we read):
- **podcast**: `id` (int), `title`, `xmlURL`, `imgURL`, `description`,
`language`.
- **episode**: `id` (int), `title`, `guid`, **`enclosure`** (audio URL),
`podcast_id`, `duration` (seconds), `pubdate`.
The episode's `enclosure` is the only field playback needs. The single
episode endpoint does not carry the podcast's title, so an episode fetched
on its own has no "artist" until we look the podcast up (see D4).
## Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by `/youtube`): mirroring tidal's search-term semantics costs
no TUI or wire change. No proto change, no new `ProviderCommand`.
- fyyd's public API needs no credentials, so — unlike tidal — a missing or
empty `fyyd.toml` is the normal case, and init never needs a login. The
provider is non-fatal at startup like the local providers: a build/parse
failure only costs the `/fyyd` subtree.
- The audio player streams plain https `enclosure` URLs directly
(`audio-player` windowed-HTTP path). Podcast enclosures are ordinary
media files, so no `/youtube`-style URL-lifetime or ~1 MiB-cap problem.
- Episodes are addressed by fyyd's stable numeric `id` (as a string
segment). We do not use the RSS `guid` as the path key — the fyyd id is
shorter, already URL-safe, and is what the episode endpoint takes.
## Decisions
### D1 — Crate `fyyd`, mounted at `/fyyd`, non-fatal init
New workspace crate `fyyd` implementing `ProviderClient`, shaped on `ytdy`
(the closest analog: remote, search-driven, in-memory terms). Wired into
`ProviderOrchestrator` with a `fyyd_client: Option<Arc<fyyd::Client>>`
field, `fyyd_owns()`/`fyyd_provider()` helpers, a `build()` block that
reads `fyyd.toml` (non-fatal), a `get_lib_root` child gated on
`self.fyyd_client.is_some()`, and one routing arm in each dispatch method.
`crabidy-server`'s settings gain `fyyd` in `ALL_PROVIDERS`, in
`ProviderToggles`, in `all()`, and in `provider_toggles()`.
### D2 — HTTP behind a trait, faked in tests
All network access goes through one seam — a `Fyyd` trait (`search_podcasts`,
`hot_podcasts`, `podcast_episodes`, `episode`) behind `Box<dyn Fyyd>` — with
a `reqwest`-based `FyydApi` for production and a `FakeApi` in tests, exactly
as `ytdy` hides `rustypipe` behind `Extract`. Provider logic (tree shaping,
path parsing, term store) is then unit-tested with zero network. The trait's
error type maps to `ProviderError::FetchError` at the boundary; malformed
paths are `MalformedPath`; empty create/rename input is `InvalidInput`.
### D3 — Tree shape (the extra level)
- `/fyyd` — children: `search` (always, `is_creatable`), `hot` (always, a
fixed featured-podcasts browse so the provider is useful with zero
typing).
- `/fyyd/search``is_creatable`; children are the in-memory search terms
(`RwLock<Vec<String>>`, dedup, recreated implicitly on stale paths),
each `is_editable` + `is_deletable`, like tidal/youtube.
- `/fyyd/search/<term>` — lists the top-N matching **podcasts** as
children (containers, not tracks). Not directly queueable itself; each
podcast child *is* queueable.
- `/fyyd/search/<term>/<podcast-id>` — lists that podcast's episodes as
**tracks**; queueable and downloadable (homogeneous tracks). Queueing the
podcast node enqueues its listed episodes (bounded, see D5).
- `/fyyd/search/<term>/<podcast-id>/<episode-id>` — the track leaf.
- `/fyyd/hot` — lists featured podcasts as children (same podcast shape as
a search-term node, but the list comes from `/feature/podcast/hot`
instead of a term).
- `/fyyd/hot/<podcast-id>` and `/fyyd/hot/<podcast-id>/<episode-id>`
identical podcast/episode shapes as under `search`; the two branches
share the podcast-node and episode-leaf builders.
Terms are percent-encoded into one segment (`encode_segment`/
`decode_segment`); podcast and episode ids are already URL-safe integers.
### D4 — Streams, metadata, artist
- `get_urls_for_track`: parse the episode id from the path →
`episode(id)``vec![enclosure]`. An empty/missing enclosure is
`FetchError`, not a panic; the queue skips it.
- Track fields: `title` = episode title, `artist` = the **podcast** title,
`duration` = episode duration (seconds → `Option<u32>`),
`provider_item_id` = fyyd episode id (keys the content store for
captures), `album` = `None`.
- **Artist source.** When episodes are listed under a podcast we already
hold the podcast title, so listed tracks get the right artist for free.
A *directly* fetched episode (`get_metadata_for_track` on a bare episode
path, or a stream resolve) does not carry the podcast title from the
episode endpoint; the `FyydApi` fills it with one extra `/podcast`
lookup, and the `Episode` model carries `artist: Option<String>` so the
fake can supply it in tests. A missing artist degrades to an empty
string, never an error.
### D5 — Bounds and freshness
- `search_results` (podcasts per term, default 20), `hot_count` (default
20), and `episodes_per_podcast` (default 100) are configurable in
`fyyd.toml`; every listing is capped so a huge podcast cannot stall the
library or queue resolution. A `call_timeout_secs` (default 30) bounds
each HTTP call (hard rule: timeouts on external calls).
- Listings are fetched fresh per call (no cross-call cache), like tidal's
and youtube's fresh remote calls. Only the search *terms* are stored, in
memory.
### D6 — Out of scope (explicitly)
- fyyd user accounts, OAuth, subscriptions, personal collections, or the
"hot languages" / category browse — search + hot cover the "find and
play" ask.
- Episode chapters, transcripts, or per-episode images in the queue.
- Pagination past the configured caps (one page is fetched per listing).
- Caching or persisting episodes to disk beyond the existing `W` captures.
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
fyyd: "fyyd (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory, like tidal)"
api: "FyydApi\n(reqwest seam: Fyyd trait)"
client -> terms
client -> api
}
svc: "api.fyyd.de\n(public, keyless)" { shape: cloud }
cdn: "podcast enclosure\n(RSS media host)" { shape: cloud }
player: audio-player { shape: hexagon }
server.orch -> fyyd.client: "/fyyd/..."
fyyd.api -> svc: "search / episodes / hot (JSON, timeout)"
server.orch -> player: "enclosure URL"
player -> cdn: "windowed HTTP stream"
```
## Key flow: search a podcast, play an episode
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
f: fyyd
api: api.fyyd.de
tui -> orch: "% on /fyyd/search: 'history'"
orch -> f: "create_lib_node"
f -> api: "GET /search/podcast?term=history"
api -> f: "podcasts (id, title)"
f -> tui: "term node: podcasts as children"
tui -> orch: "open a podcast"
orch -> f: "get_lib_node(/fyyd/search/history/<pid>)"
f -> api: "GET /podcast/episodes?podcast_id=<pid>"
api -> f: "episodes (id, title, enclosure, duration)"
f -> tui: "podcast node: episodes as tracks (queueable)"
tui -> orch: "queue + play an episode"
orch -> f: "get_urls_for_track(.../<eid>)"
f -> api: "GET /episode?episode_id=<eid>"
api -> f: "enclosure URL"
orch -> orch: "player streams the enclosure"
```
## Risks and open questions
- **fyyd envelope / field drift.** The `data` envelope and field names
(`enclosure`, `xmlURL`, numeric `id`) are read from the current public
docs but were not live-validated during design. The `FyydApi` decodes
defensively (serde with `#[serde(default)]`, missing fields degrade, no
panic) and every failure is a typed `FetchError`; if a field name is
wrong the fix is local to `FyydApi`'s DTOs. **Live validation is a
task-plan gate.**
- **Episode enclosure availability / expiry.** Some feeds proxy or expire
enclosures; a dead URL surfaces as a skipped track, never a crash.
- **Artist double-fetch.** Filling a directly-fetched episode's artist
costs one extra `/podcast` call; acceptable because direct metadata
fetches are rare (listing is the common path and already has the title).
- **Rate limits.** fyyd does not document limits; the per-call timeout and
the absence of background polling keep request volume to user actions.

View File

@ -1,162 +0,0 @@
# Help modal for cbd-tui
## Context and problem statement
`cbd-tui` is the ratatui/crossterm terminal client for crabidy. All keyboard
handling lives in a single `match (app.focus, key.modifiers, key.code)` in
`main.rs` (`run_ui`), covering global bindings plus per-pane bindings for the
two focusable panes (`UiFocus::Library`, `UiFocus::Queue`). None of this is
discoverable from inside the app: a new user has to read the source to learn
that `Tab` cycles panes or that `a` appends the selected library node to the
queue.
Goal: pressing `?` opens a help modal that explains basic usage (panes, focus
cycling) and lists all keyboard shortcuts; a key press closes it again.
## Assumptions (confirmed)
- The help content must not be able to drift from the real bindings — the
binding table becomes the single source of truth for both dispatch and help
rendering (confirmed with user; see Options).
- The modal is read-only and modal in the strict sense: while it is open, all
other bindings are inert. `?`, `Esc`, and `q` close it (`q` therefore does
**not** quit the app while help is open).
- Bindings stay hardcoded for now. User-configurable keymaps are out of scope,
but the table design must not preclude them later.
- The modal shows **all** scopes (Global, Library, Queue) grouped, not just the
bindings of the currently focused pane — the point is discovery.
- No new dependencies; ratatui's `Clear` widget plus a centered `Rect` is
enough for the overlay.
## Options considered
### Option A — static help text, dispatch untouched
A display-only `const HELP: &[(&str, &str, &str)]` table rendered by the
modal; the existing `match` in `main.rs` stays as-is.
- **Pros**: smallest diff; zero refactor risk.
- **Cons**: two parallel encodings of the same facts; every binding change now
has a silently skippable second edit site. Historically this is exactly the
kind of table that rots.
### Option B — declarative binding table (chosen)
Introduce `app/bindings.rs`:
- `Scope``Global | Library | Queue`, mirroring `UiFocus` plus a global tier.
- `Action` — one variant per user-visible operation (`Quit`, `TogglePlay`,
`VolumeUp`, `LibraryDown`, `QueueRemoveTrack`, …).
- `Binding { scope, mods, code, action, description }` with
`const BINDINGS: &[Binding]`.
- `lookup(focus: UiFocus, key: KeyEvent) -> Option<Action>` — scope-aware
table scan (global entries match in any focus; pane entries only when that
pane is focused).
- A `key_label(mods, code) -> String` formatter so the help modal derives the
displayed key from the same data dispatch uses (no hand-written "Ctrl+d"
strings).
The event loop shrinks to: translate `KeyEvent``Action` via `lookup`, then
one `match action` executes it (`App::dispatch`). The help modal renders
`BINDINGS` grouped by `Scope`.
- **Pros**: single source of truth; help cannot drift; the loop's 30-arm match
becomes data; natural seam for configurable keymaps later.
- **Cons**: moderate refactor of `run_ui`; `Action` execution needs access to
both `&mut App` and the `Sender<MessageFromUi>` (solved by giving `App` its
own `tx`, which it already receives in `App::new`).
**Decision: Option B**, confirmed with the user.
## Structure
```d2
direction: right
main: main.rs run_ui loop {
poll: crossterm event poll
}
app: app module {
bindings: bindings.rs {
table: "BINDINGS: &[Binding]"
lookup: "lookup(focus, key) -> Option<Action>"
label: "key_label(mods, code)"
}
state: App {
focus: "focus: UiFocus"
help: "show_help: bool"
dispatch: "dispatch(action)"
}
help_modal: help.rs {
render: "render_help(frame)"
}
}
server: crabidy-server (gRPC)
main.poll -> app.bindings.lookup: KeyEvent
app.bindings.lookup -> app.state.dispatch: Action
app.bindings.table -> app.bindings.lookup: dispatch reads
app.bindings.table -> app.help_modal.render: help reads same table
app.state.dispatch -> server: MessageFromUi via tx
app.state.help -> app.help_modal.render: gates overlay
```
## Key-press flow
```d2
shape: sequence_diagram
user: User
loop: run_ui loop
bindings: bindings::lookup
app: App
user -> loop: presses "?"
loop -> bindings: lookup(focus, key)
bindings -> loop: "Some(Action::ToggleHelp)"
loop -> app: "dispatch(ToggleHelp)"
app -> app: "show_help = true"
loop -> app: render()
app -> app: draw panes, then help overlay (Clear + centered popup)
user -> loop: presses any bound key while help open
loop -> bindings: lookup sees help-open state
bindings -> loop: "only Close actions match (?, Esc, q)"
```
## Boundaries and interfaces
- **`app/bindings.rs`** owns the vocabulary: `Scope`, `Action`, `Binding`,
`BINDINGS`, `lookup`, `key_label`. Pure data + pure functions; no I/O, no
ratatui types — unit-testable without a terminal.
- **`App`** gains `show_help: bool`, a stored `tx: Sender<MessageFromUi>`, and
`dispatch(&mut self, action: Action)`. `run_ui` keeps ownership of the loop
and terminal; quitting stays a loop-level concern (`dispatch` returns a
signal or `Action::Quit` is handled in the loop — decided in api-design).
- **`app/help.rs`** renders the overlay: short usage paragraph (panes, `Tab`
to switch focus) followed by the binding table grouped by scope. Reads
`BINDINGS` only.
- Modal gating lives in one place: when `show_help` is true, `lookup` (or the
loop) only admits close actions. No other component needs to know the modal
exists.
## Risks
- **`?` and modifier reporting**: terminals differ on whether `?` arrives with
`SHIFT` set. Match `KeyCode::Char('?')` regardless of the shift modifier
(as the existing `J`/`K`/`G` arms already do for shifted letters).
- **Small terminals**: the full binding list may not fit. Initial version
clamps the popup to the frame and truncates; scrolling is an explicit
non-goal for now (open question below).
- **Refactor regressions**: converting ~30 match arms to table entries risks
transposition mistakes. Mitigated by unit tests asserting `lookup` results
for every current binding (quality-gates stage).
## Open questions
- Should the help modal scroll when the terminal is too small, or is
truncation with a "…" indicator acceptable? (Default: truncate.)
- Mouse support is enabled (`EnableMouseCapture`) but unused; clicking outside
the modal to close it is a possible later nicety, not in scope.

View File

@ -1,198 +0,0 @@
# Incremental captures, skipped tracks, and capture progress
## Context and problem statement
Download captures (`W`, architecture/captures.md) are all-or-nothing: the
whole subtree is built in a hidden temp folder and swapped into place; any
failure destroys everything downloaded so far. For a large node that means
hours of downloading can evaporate on one bad track, and re-running restarts
from zero. Mixed-provider sources (queues, bookmarks) silently *omit*
uncapturable tracks, so the capture's track list quietly diverges from the
source. And while a capture runs, the user sees nothing — worse, the TUI's
poll loop awaits the capture RPC, so the client is effectively frozen until
the capture finishes.
This design makes download captures **incremental and resumable**, records
uncapturable tracks as a first-class **skipped** playable, streams **capture
progress** to clients, and warns about long captures up front. A small,
unrelated fix rides along: colored library items (editable/creatable/marked)
are unreadable under the focused selection bar (D7).
## Assumptions
- "Capture" here means the *download* capture (`W`). Bookmark captures (`w`)
stay atomic tmp-and-swap: they are cheap, and "overwrite = refresh" is the
right semantic for links. They do adopt the skipped playable for skipped
source tracks (D1) and report progress (D5).
- Resuming keys on the **name**: capturing into an existing capture name
merges into that folder. Entry identity is the deterministic toml file name
(`NNNN <title>.cbd-track.toml`), so resuming assumes the source keeps its
order — appending to a queue is fine, reordering it re-captures under new
names and leaves stale files behind (the user can delete the capture and
start over). Accepted.
- One capture per name at a time is the user's responsibility (same as the
old racing-tmp behavior); concurrent same-name captures interleave per
file, last writer wins. Accepted.
## D1 — A `skipped` playable
Track files get a fourth playable: `[playable] skipped = true`, validated
with the same exactly-one cardinality as `file`/`url`/`link`
(`skipped = false` counts as unset and is rejected). Semantics: *this
position in the tree is a real track whose audio could not be captured*.
- `fsdy::Playable::Skipped`; `TrackFile::from_track_skipped(track)` builds
one from a wire track.
- Wire: `Track.is_skipped` (proto field 6). `TrackFile::to_track` sets it;
the track's `path` stays the lib path (like `file`), there is nothing to
route to.
- `TrackFile::from_track` (queue persistence, bookmarks) writes a skipped
playable when the source track `is_skipped` — skipped-ness survives queue
persistence and bookmark round trips instead of degrading into a dead
link.
- `get_urls_for_track` on a skipped file returns `ProviderError::FetchError`
(playback never asks, see D4; direct callers get a normal typed error).
Alternative considered: model skipped-ness as *absence* (keep omitting the
track) plus a client-side diff against the source. Rejected — the source may
be gone tomorrow; the capture itself must record the gap.
## D2 — Incremental download captures
`capture_into` splits into two phases:
1. **Enumerate**: the existing iterative pre-order walk collects every
directory and track (with its listing index) first, enforcing
`max_dirs`/`max_tracks`. This makes the total known before the first
download — progress can be a real ratio — and costs only metadata calls.
2. **Fetch**: process the collected tracks in order, feeding progress after
each one.
The sink decides the write mode:
- `Sink::Link` (bookmarks): unchanged tmp-and-swap into `.tmp-<name>`,
all-or-nothing.
- `Sink::Download` (captures): writes **directly** into `dir/<name>/`,
creating directories as needed, never deleting existing content. Per
track, in order:
- The target toml exists, parses, and its playable is *not* skipped, and
(for a `file` playable) the referenced audio exists → **reuse** (counts
as done, no download). A `url`/`link` playable also counts as satisfied
— only this store writes here, but hand-edited files should not be
clobbered.
- Otherwise the track is (re)captured. A source that resolves to a
local **file** path rather than an http(s) URL — an fs playable, or a
track from an existing capture — is **copied** into the capture next
to its toml (counting against the same byte budget as a download), so
a queue mixing streamed and local tracks captures fully. A source
that genuinely cannot be captured — the track is itself skipped, its
stream fails to resolve, resolves to nothing, or the local file is
missing/unreadable — writes a **skipped toml** (`from_track_skipped`)
and counts as skipped. This replaces the old silent omission (and the
older behavior of skipping local files outright).
- A real download failure (HTTP status, transport, timeout, byte budget)
**aborts the run but keeps everything written so far** — re-running the
same name resumes exactly where it stopped, re-attempting skipped and
missing entries only.
Audio is still written before its toml, so a crash mid-download leaves a
toml-less audio file that the resume simply re-downloads (truncating on
create). The byte budget counts only bytes downloaded *this run*, so resuming
a large capture is never starved by what is already on disk.
```d2
direction: right
walk: capture_into {
enumerate: "phase 1: enumerate\n(dirs + tracks, caps)"
fetch: "phase 2: fetch\n(per track, in order)"
enumerate -> fetch: "total known"
}
walk.fetch -> reuse: "toml ok + audio present"
walk.fetch -> skipped: "source uncapturable\n→ skipped = true toml"
walk.fetch -> download: "download + toml"
walk.fetch -> abort: "download failure\n(keeps progress)"
```
## D3 — Skipped tracks in the queue
Skipped tracks queue like any other (the user sees the gap instead of a
silently shorter queue). The TUI renders them red; playback skips them.
`Playback::play` already loops past tracks whose URLs fail to resolve. It now
additionally:
- skips `is_skipped` tracks **without a provider round trip**, and
- bounds the whole skip loop by the queue length at entry — an all-skipped
queue with repeat on used to be an infinite provider-hammering spin; now
it stops the player with a warning after one full pass.
## D4 — Capture progress on the update stream
New stream update (proto):
```proto
message CaptureProgress {
string name = 1; // capture / bookmark name
bool download = 2; // W capture vs w bookmark
uint32 tracks_done = 3; // settled: reused + downloaded + linked +
// skipped — reaches tracks_total on success
uint32 tracks_total = 4; // known after enumeration (0 until then)
uint32 tracks_skipped = 5; // of those, skipped tomls written this run
bool finished = 6;
string error = 7; // set iff finished with a failure
}
```
`CaptureLibraryNode` now returns once the capture is **accepted**: the
provider validates the name, the store, and the source's download blessing,
replies, and runs the walk on its spawned task, streaming `CaptureProgress`
through a bounded channel that the RPC layer forwards into the existing
update broadcast. Completion and failure arrive as the final progress event
(`finished`, `error`), not as the RPC result.
Rationale: the TUI's orchestration loop `select!`s over one RPC at a time —
a capture RPC that lasts an hour freezes every other interaction. Validation
errors still come back synchronously with the old status mapping; walk
errors move to the stream (and the server log, as before).
## D5 — TUI: progress, red skipped tracks, warnings
- **Skipped tracks are red** (and not bold) in both the queue and library
listings, driven by `Track.is_skipped`. The playing-track red keeps
precedence in the queue.
- **Progress lines** render at the bottom of the library pane, one per
active capture: `capturing <name> 12/34 (2 skipped)` (bookmarks:
`bookmarking`). A finished capture lingers ~5 s as
`captured <name>: 34 tracks (2 skipped)`; a failed one shows the error in
red for ~10 s. State lives in the `App`, fed by the update stream; the
100 ms render tick handles expiry.
- **Warnings**: the `W` help-table description and the capture input
overlay's label both say a download capture can take a long time (and that
re-capturing the same name resumes it).
## D6 — Out of scope
- Cancelling a running capture from the TUI.
- Retrying real download failures within a run (rerun-to-resume covers it).
- Garbage-collecting stale entries when the source shrank or reordered.
- Multi-hop link resolution for skipped detection (a link whose target is a
skipped file plays as a normal link failure).
## D7 — Focused-selection contrast fix
Library items styled with a foreground color (creatable/editable/deletable →
secondary, marked → green; queue: skipped/current → red) are hard to read
when the focused selection bar (`bg = COLOR_PRIMARY`, a light blue) sits on
them. Fix: when an item is the selected row of a *focused* pane, its
foreground switches to the dark `COLOR_PRIMARY_DARK` so it reads against the
light bar. The unfocused bar is dark and keeps the colored foregrounds.
## Risks
- Enumerate-then-fetch holds the full entry list in memory: bounded by
`max_tracks` (500 download / 20 000 bookmark) — fine.
- A source whose listing order changes between runs duplicates content under
new prefixes (assumption above). Accepted; documented in the help text via
the "resumes by name" phrasing.
- The progress channel is bounded (64); a slow broadcast consumer only slows
the walk, never blocks it permanently (the forwarder drains continuously).

View File

@ -1,265 +0,0 @@
# jamendo provider (free / Creative-Commons music)
## Context and problem statement
A new library provider mounted at `/jamendo` that lets a user **search and
play** the Jamendo catalogue — hundreds of thousands of Creative-Commons
tracks — and browse the album a track belongs to, with **capture/download**
coming for free.
Jamendo is a **remote, search-driven** streaming service like
tidal/soundcloud/fyyd, but it is the *easy* one, and the design leans on that:
- **It has a real, stable, official API** (`api.jamendo.com/v3.0`). Unlike
SoundCloud there is **no `client_id` scraping and no rotation**: the developer
registers a `client_id` once at `devportal.jamendo.com` and drops it in
`jamendo.toml`. Every request just carries `client_id` + `format=json`.
- **The public catalogue needs no login.** Browsing and streaming are anonymous
with only a `client_id`; OAuth 2.0 exists solely for a user's own account
(favourites, personal playlists) and is **out of scope for v1**. So there is
no token lifecycle, no optional-login branch — the whole provider is one flat
public surface.
- **Tracks stream as a plain MP3 URL.** Each track object carries an `audio`
field that is a direct, range-streamable MP3 (`mp3d.jamendo.com/...`). crabidy
streams a single byte source over its existing windowed-HTTP path, so —
unlike SoundCloud's HLS work — **there is no `audio-player` change at all**.
This is the `absdy` shape: nodes serve tracks, `get_urls_for_track` returns a
URL, the existing MP3 (symphonia) decoder handles the rest.
- **Duration is already in seconds**, which is exactly the unit
`Track.duration` carries (soundclouddy divides its ms by 1000 at
`lib.rs:436`; absdy passes seconds straight through). No conversion, no
repeat of the web ms/seconds bug.
- **Captures** (`W`, download) come for free once nodes serve tracks and raise
`is_downloadable`: Jamendo tracks carry an `audiodownload` URL and the
content is CC-licensed and explicitly downloadable. No wire or TUI work.
## The Jamendo API (grounding)
Base `https://api.jamendo.com/v3.0`. **Every** request carries
`client_id=<id>&format=json` (omitted from the cells below). List calls add
`&limit=<n>&offset=<o>`; `limit` max is **200** (default 10). Endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Search tracks | `GET /tracks?search=<t>` (or `namesearch`, `tags`) |
| Track detail (stream URL) | `GET /tracks?id=<id>` |
| Search albums | `GET /albums?namesearch=<t>` |
| Album's tracks | `GET /albums/tracks?id=<album_id>` |
Objects (fields we read):
- **track**: `id` (numeric string), `name` (→ title), `artist_name`,
`album_name`, `duration` (**seconds**), `audio` (direct streaming MP3 URL),
`audiodownload` (download URL), `license_ccurl`. `audioformat=mp32` requests
the higher-bitrate stream (default is a low-bitrate `mp31`).
- **album**: `id`, `name`, `artist_name`; `/albums/tracks` returns the album
wrapping a `tracks[]` array of the same track shape.
**A `User-Agent` header is mandatory** (live-discovered 2026-07-24): Jamendo's
API returns HTTP 200 `success` with an **empty** result set to any request that
carries none — and `reqwest` sends none by default — so `JamApi` sets one. This
is silent (no error), so a missing UA looks exactly like "no matches".
Search parameters: `search` (free text across track/album/artist/tags),
`namesearch` (name match), `tags` (AND) / `fuzzytags` (fuzzy OR),
`order` (relevance, popularity, downloads, listens, releasedate, …). v1 uses
`search` with the default relevance order.
## Assumptions (decided here)
- The captures / creatable / editable / deletable TUI + wire flows are
provider-agnostic (proven by `/youtube`, `/fyyd`, `/abs`, `/soundcloud`):
a search-term provider costs **no proto, wire, TUI, or `ProviderCommand`
change**. It is a pure path-prefix subtree.
- The Jamendo `audio` URL is a real streaming MP3 the existing windowed-HTTP
source plays unmodified — **no HLS, no new `audio-player` component**. (Risk
R1 gates this with a live play.)
- A missing / malformed / rejected `client_id` must **never crash startup or a
browse**. With no `client_id` the `/jamendo` subtree is simply **not
mounted** (like `/abs` with missing config); a `client_id` that the API later
rejects surfaces a typed error and degrades the subtree, never the app.
- `client_id` is a semi-secret account key: **redact it from `Debug`, config
dumps, and logs**, and never log a built stream URL (they can embed a signed
`from` token). (Hard rule: redact secrets.)
- Jamendo ids are numeric and URL-safe; only user-typed **search terms** are
percent-encoded into a path segment.
## Decisions
### D1 — Crate `jamendody`, mounted at `/jamendo`, non-fatal init
New workspace crate `jamendody` implementing `ProviderClient`, shaped on
`absdy`/`soundclouddy` (remote, search-driven, plain leaf tracks with direct
URLs). Wired into `ProviderOrchestrator` with a
`jamendo_client: Option<Arc<jamendody::Client>>` field, `jamendo_owns()` /
`jamendo_provider()` helpers, a `build()` block that reads `jamendo.toml`
(non-fatal — absent or `client_id`-less ⇒ `None`), a `get_lib_root` child gated
on `self.jamendo_client.is_some()`, and one routing arm in each dispatch method.
`crabidy-server` settings gain `jamendo` in `ALL_PROVIDERS` (8 → 9), in
`ProviderToggles`, in the defaults, and in `provider_toggles()`. No
`cli.rs` / `main.rs` change.
### D2 — HTTP behind a trait, faked in tests
All network access goes through one seam — a `Jam` trait
(`search_tracks`, `search_albums`, `album_tracks`, `track_detail`) behind
`Box<dyn Jam>` — with a `reqwest`-based `JamApi` for production and a `FakeApi`
in tests (as `absdy` hides `reqwest` behind `Abs`, `soundclouddy` behind `Sc`).
Provider logic (tree shaping, path parsing, term store) is unit-tested with zero
network. Errors map to `ProviderError::FetchError` at the boundary; malformed
paths → `MalformedPath`; empty create/rename → `InvalidInput`. Every call is
bounded by `call_timeout_secs` (D5, hard rule: timeouts on external calls).
### D3 — Tree shape (search → tracks + albums; canonical leaves)
Track and album ids are both numeric, so canonical paths are **type-tagged** to
disambiguate: `track/<id>` (leaf) and `album/<id>` (container). Browse nodes
point their children at these canonical paths, so playback and album expansion
never depend on the branch they were reached through.
- `/jamendo` — child: `search` (creatable). Not itself queueable.
- `/jamendo/search``is_creatable`; children are the in-memory search terms
(`RwLock<Vec<String>>`, dedup), each `is_editable` + `is_deletable`, exactly
like the tidal/youtube/soundcloud search stores.
- `/jamendo/search/<term>` — the results: matching **tracks** as queueable
leaves (pointing at `/jamendo/track/<id>`) and matching **albums** as
queueable containers (pointing at `/jamendo/album/<id>`).
- `/jamendo/album/<id>` — the album's tracks (queueable, downloadable); the
canonical container path.
- `/jamendo/track/<id>` — the canonical **track leaf**. A track id alone
resolves a stream, so every branch's track children point here and playback
needs no browse context.
Terms are percent-encoded into one segment (`encode_segment` / `decode_segment`,
shared helpers already used by the other search providers); numeric ids are
already URL-safe.
### D4 — Playback: direct MP3, no player change
`get_urls_for_track(/jamendo/track/<id>)`: `GET /tracks?id=<id>&audioformat=…`,
read `audio`, and **return it as `urls[0]`** (the player consumes only the
first). One API round-trip because the URL can embed a signed token; a track
with no `audio` (unstreamable) surfaces `NotStreamable`/`NotFound` and is
skipped, never a crash. The URL is a normal HTTP MP3 → the existing
`WindowedHttpStream` + symphonia MP3 decoder play it unchanged; `open_source`
routing is untouched. Track fields: `title` = `name`, `artist` = `artist_name`,
`album` = `Album { title: album_name }` when present else `None`,
`duration` = `duration` seconds → `Option<u32>` (filtered `> 0`),
`provider_item_id` = `"track:<id>"` (keys the capture store), and nodes/tracks
raise `is_downloadable` (backed by `audiodownload`).
### D5 — Auth and bounds
- `Settings`: `client_id: String` (**required** — without it the provider does
not mount), `audioformat: Option<String>` (default `mp32`),
`search_results: usize` (default 50, capped at the API's 200),
`album_tracks_limit: usize` (default 200), `call_timeout_secs: u64`
(default 30). Hand-written `Debug` redacts `client_id`.
- No scraping, no OAuth, no token refresh in v1 — the `client_id` is read once
from config and used on every call. An API `401`/`403` (revoked/invalid key)
maps to a typed `FetchError`; the subtree degrades, the app survives.
- Caps are `log`-ged when they truncate a listing, so truncation is visible,
not silent (hard rule: no silent caps). Listings are fetched fresh per call
(no cross-call cache), like the other remote providers; only search terms are
held in memory.
### D6 — Out of scope (explicitly)
- **OAuth user features**: personal favourites, a user's own playlists, and
writing to a Jamendo account. Additive later behind an optional token,
mirroring the soundcloud "login optional" branch.
- **Tag / genre / popular / radio browse** and **artist browse**: v1 is
search-driven (`search` → tracks + albums). Tag and popularity browse nodes
are a clean phase-2 add (same DTOs, new root children).
- **Pagination past the configured caps** (one page per listing).
- **Download-format negotiation** beyond the single `audioformat` setting.
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
jam: "jamendody (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory RwLock<Vec>)"
api: "JamApi\n(reqwest seam: Jam trait)"
client -> terms
client -> api
}
player: "audio-player" {
http: "WindowedHttpStream\n(existing, unchanged)"
dec: "rodio / symphonia (mp3)"
http -> dec: "mp3 bytes"
}
japi: "Jamendo api.jamendo.com/v3.0" { shape: cloud }
cdn: "mp3d.jamendo.com (MP3)" { shape: cloud }
server.orch -> jam.client: "/jamendo/..."
jam.api -> japi: "search / tracks / albums (JSON, client_id, timeout)"
server.orch -> player.http: "audio MP3 URL"
player.http -> cdn: "GET mp3 (range)"
```
## Key flow: search and play a track
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
j: jamendody
api: "Jamendo api-v3"
http: "WindowedHttpStream"
cdn: "mp3d.jamendo.com"
tui -> orch: "open /jamendo/search"
tui -> orch: "create term \"lofi piano\""
orch -> j: "create_lib_node(search, term)"
j -> tui: "term stored"
tui -> orch: "open /jamendo/search/<term>"
orch -> j: "get_lib_node"
j -> api: "GET /tracks?search=…&client_id="
api -> j: "tracks (id, name, artist, album, duration s, audio)"
j -> tui: "tracks as leaves (/jamendo/track/<id>) + albums"
tui -> orch: "queue + play a track"
orch -> j: "get_urls_for_track(/jamendo/track/<id>)"
j -> api: "GET /tracks?id=<id> → read audio URL"
j -> orch: "urls = [ audio ]"
orch -> http: "player.play(audio)"
http -> cdn: "GET mp3 (range) → symphonia decodes"
```
## Boundaries / interfaces
- **Inbound**: `ProviderClient` (crabidy-core) — the orchestrator dispatches
`/jamendo/...` paths here. No new trait methods; search-term semantics reuse
`create_lib_node` / `rename_lib_node` / `delete_lib_node`.
- **Outbound**: the `Jam` trait (network seam) — the only place `reqwest` and
the `client_id` live. Everything above it is pure and unit-tested.
- **Config**: `jamendo.toml` (`client_id`, optional bounds), round-tripped via
`settings()`; wired through `crabidy-server` settings like every provider.
## Risks and open questions
- **R1 — `audio` URL plays on the windowed-HTTP path.** The whole
"no player change" claim rests on the `audio` MP3 streaming cleanly (range
requests, clean EOS, correct duration from metadata). **Live-test gate**: play
a Jamendo track end-to-end and confirm no panic, correct seek bar, clean EOS.
If a signed URL turns out non-range or short-lived, the fallback is the same
as `/youtube`: resolve-just-before-play and treat a stale URL as a skipped
track.
- **R2 — `audioformat` availability.** `mp32` may not exist for every track;
decode defensively and fall back to whatever `audio` the listing returned
(the `audio` field already reflects the requested format or the default).
- **R3 — field / envelope drift.** DTOs decode defensively
(`#[serde(default)]`, ids as strings); a renamed field is a local fix in
`JamApi`. Live validation is a task-plan gate.
- **R4 — `client_id` validity at startup.** We do not verify the key at init
(no blocking network in `build()`); the first browse reveals a bad key as a
typed `FetchError`. Acceptable — matches how the other remote providers fail
lazily rather than at boot.

View File

@ -1,250 +0,0 @@
# MPRIS: the desktop's media player
## Context
On a Linux desktop the media keys (`XF86AudioPlay`, `XF86AudioNext`, …)
and every "now playing" status-bar module speak one protocol:
**MPRIS2**, a pair of D-Bus interfaces (`org.mpris.MediaPlayer2` and
`org.mpris.MediaPlayer2.Player`) served at `/org/mpris/MediaPlayer2` under
a bus name `org.mpris.MediaPlayer2.<something>`. GNOME and KDE route the
keys themselves; sway/i3 users bind them to `playerctl`; waybar, polybar
and friends poll the same interfaces for the track.
crabidy has never spoken it. The TUI's desktop *notifications*
(`notify-rust`, feature `notifications`) are the only thing it puts on the
bus, and a transient popup is neither a status-bar entry nor a key target
— which is why the player looks absent to the desktop even though a
notification appears on every track change.
Everything the protocol needs is already on the wire: `TogglePlay`,
`Stop`, `Next`, `Prev`, `Seek`, `ChangeVolume`, `ToggleMute`,
`ToggleShuffle`, `ToggleRepeat` as RPCs, and `GetUpdateStream` pushing
track, play state, position, volume, mute and the queue modifiers. So
this feature is a **translation layer**, not new player state.
Goal: media keys control the server, and the desktop can show what is
playing, without a second copy of the truth anywhere.
## Assumptions
- **A1 — MPRIS is a desktop-session concern.** It is meaningful only
where the session bus is, which is where a *client* runs, not
necessarily where the server runs (a Pi in the hallway has no session
bus and nobody's media keys).
- **A2 — The server owns all state.** Same rule as every other client
surface (`architecture/seek.md` A2): a client renders what the update
stream told it and predicts nothing.
- **A3 — Fire-and-forget.** A media key becomes an ordinary playback RPC.
If it is refused the server logs it; MPRIS reports success, because the
gesture *was* delivered.
- **A4 — A missing session bus is normal.** `cbd-tui` over ssh, in a
tty, in a container. It must lose MPRIS and nothing else.
## Options considered
### Where the MPRIS player lives
**Option A — in `crabidy-server`.** The server genuinely *is* the player,
so position and state need no round trip. But it is the wrong machine: the
server is routinely headless or remote (A1), where the interface would
have no bus to sit on and no keys to serve; and it would put a
desktop-integration dependency in the daemon.
**Option B — in `cbd-tui`, behind a feature *(chosen)*.** The TUI already
holds a live update stream and a command channel; MPRIS becomes a second
front-end onto both, ~400 lines and no new state. It exists exactly while
a TUI does, which is also its limit: close the TUI and the media keys go
quiet.
**Option C — a separate `cbd-mpris` bridge binary.** A headless client
that registers MPRIS and proxies gRPC, run as a systemd user service, so
the keys work whether or not a TUI is open. Strictly more capable than B
and strictly more machinery: another binary, another config and auth path,
a service unit to install.
**Decision: Option B**, with the translation kept in one module whose only
inputs are an update stream and a command sender — the two things a
future Option C bridge would also have. C stays a wrapper away
(Deferred).
### Which D-Bus crate
`mpris-server` (on `zbus`) against `souvlaki` (cross-platform, on
`dbus-rs`). `zbus` speaks the protocol in pure Rust, needs no `libdbus`
and no `pkg-config`, and is **already in the dependency graph** via
`notify-rust` — so on Linux the whole feature costs one small crate and
nothing to install. `souvlaki`'s portability buys nothing here: MPRIS is
the Linux desktop, and the Windows/macOS backends have no crabidy to talk
to. `mpris-server` also models the spec directly (`RootInterface` +
`PlayerInterface`), so the mapping decisions below are visible in the
code instead of buried in a helper.
## Decisions
- **D1 — `cbd-tui`, feature `mpris`, on by default.** Same shape as
`notifications` (`architecture/build-features.md` D1): the feature pays
for itself in a dependency (`mpris-server`), so it earns a flag; it is
in `default` because a desktop build wants it, and `cbd` forwards it.
`--no-default-features` on the TUI drops the bus entirely.
- **D2 — Publish, never predict.** The MPRIS state is a mirror of the
update stream: `QueueTrack``Metadata`, `PlayState`
`PlaybackStatus`, `TrackPosition``Position`, `Volume`/`Mute` →
`Volume`, `Mods``Shuffle`/`LoopStatus`, `Queue` → its *length* only.
Nothing is written locally on a method call and un-written if the server
disagrees.
- **D3 — Absolute MPRIS calls become deltas against the published
state.** The protocol says `Play`, `Pause`, `SetVolume`,
`SetShuffle`, `SetLoopStatus`; the server offers toggles and a volume
delta. The last state the server broadcast is the base:
- `Play` sends `TogglePlay` **only if** not already playing, `Pause`
only if playing, `PlayPause` unconditionally. So a "pause" key can
never start playback — the bug a bare toggle would have.
- `SetVolume(v)` sends `ChangeVolume(v - published)`.
- `SetShuffle`/`SetLoopStatus` toggle only when the target differs.
The base can be one broadcast stale, so a command racing a change may
become a no-op or a double toggle; the next broadcast repairs the
published state either way. Predicting instead (A2) would trade a rare
no-op for a permanently possible lie.
- **D4 — Mute is expressed as volume 0.** MPRIS has no mute property, so
the published `Volume` is 0.0 while the server is muted — which is what
a status bar should show. The *setter* works off the true level:
`SetVolume(0)` mutes (preserving the level, so unmuting restores it),
and a non-zero target unmutes first and then sends the delta. The two
commands travel one ordered channel into one sequential RPC loop, so
they cannot arrive reversed.
- **D5 — `Stop` gets its own client message.** The RPC has always
existed and no client used it. MPRIS's `CanControl` implies `Stop`, and
mapping it onto pause would be a lie the desktop cannot see through, so
`MessageFromUi::Stop` and `RpcClient::stop` are added.
- **D6 — `LoopStatus`: `Track` is refused.** The server's repeat is a
`bool` over the queue, so `None` ⇄ off and `Playlist` ⇄ on. Setting
`Track` returns `NotSupported` rather than silently doing something
else. A per-track repeat would be a server feature, not a mapping
trick.
- **D7 — `PlayState::Loading` publishes as `Playing`.** Loading is the
gap while a track opens; the desktop has only three states, and
`Paused` would make a status bar flicker "paused" on every track
change.
- **D8 — Metadata carries no URLs.** Only `mpris:trackid`,
`xesam:title`, `xesam:artist`, `xesam:album` and `mpris:length`.
`xesam:url` would have to be the *stream* URL, which clients never see
and which for several providers is a signed, credential-bearing URL —
publishing it to every peer on the session bus is exactly the leak the
redaction rule forbids. `mpris:artUrl` has no source: the `Track`
message carries no cover art. `mpris:trackid` is synthesized from the
queue position (`/org/crabidy/queue/<n>`), which is both the identity
the queue already uses and a *valid object path* — a library path is
not one (an object path element is `[A-Za-z0-9_]` only, and paths hold
spaces and unicode).
- **D9 — `mpris:length` prefers the live duration.** `TrackPosition`
carries milliseconds and is refreshed by the server; `Track.duration`
is coarse seconds and absent for streams. Use the former when non-zero,
fall back to the latter, and omit the field when neither knows —
omitted is how MPRIS says "unknown", and a zero length makes progress
bars draw a full track.
- **D10 — `Seeked` is emitted on a discontinuity, heuristically.** The
spec forbids announcing `Position` through `PropertiesChanged`;
consumers extrapolate and rely on the `Seeked` signal for jumps. The
server broadcasts positions on a 250 ms tick, so a broadcast that moves
the position by more than a second is a seek, not the clock — that is
the test, with one suppression: the reset to 0 that follows a track
change is not a seek (and the spec does not want one there).
- **D11 — The bus name carries the pid:**
`org.mpris.MediaPlayer2.crabidy.instance<pid>`, the unique-identifier
form the spec recommends. Two TUIs on one desktop then coexist instead
of one silently failing to claim the name, and the suffix costs nothing
in usability: `playerctl -p crabidy` selects it regardless (verified),
and so does anything built on playerctl's library, waybar included.
- **D12 — The update channel is bounded and drops on overflow.** 64
slots, `try_send`, a `debug!` when full. Spectrum frames and queue
contents never enter it (D2) — the only high-rate update is the
position, and a wedged bus is not worth stalling the orchestrator for.
- **D13 — No bus, no MPRIS, no error.** `Server::new` is wrapped in a
2-second timeout (a hung bus must not delay startup) and its failure is
an `info!`, after which the client runs exactly as it does today (A4).
The same for the D-Bus writes afterwards: a failed emission is logged
at `debug!` and the loop continues.
- **D14 — `CanQuit`/`CanRaise` are false.** There is no window to raise,
and letting a status-bar button close the user's terminal UI — possibly
the terminal itself — is not a courtesy. Both methods answer
`NotSupported`.
## Structure
```d2
direction: right
desktop: Desktop session {
keys: media keys\nXF86Audio*
bar: status bar\nwaybar / polybar
pctl: playerctl
}
bus: session bus {
name: org.mpris.MediaPlayer2\n.crabidy.instance<pid>
}
tui: cbd-tui {
mpris: mpris::Feed\nCrabidyPlayer
orch: orchestrate loop
ui: terminal UI thread
}
server: crabidy-server
desktop.keys -> bus.name: Play / Next / Seek
desktop.bar -> bus.name: Metadata, PlaybackStatus
desktop.pctl -> bus.name
bus.name -> tui.mpris: method call
tui.mpris -> tui.orch: MessageFromUi\n(the keybindings' own channel)
tui.orch -> server: playback RPC
server -> tui.orch: update stream
tui.orch -> tui.mpris: bounded feed\n(state only)
tui.orch -> tui.ui: MessageToUi
```
The two arrows out of `orchestrate` are the whole design: the UI thread
and the MPRIS task are peers, fed from one stream, and both send commands
through one channel.
## Boundaries
- **`cbd-tui::mpris`** owns the translation and the published mirror. Its
only inputs are a `Sender<MessageFromUi>` and stream updates; it never
touches the RPC client, the terminal, or the config.
- **`cbd-tui::lib`** starts it and forwards updates. Two call sites, one
per direction, plus a no-op stub of the same shape when the feature is
off — the pattern `notify_now_playing` already uses.
- **`cbd-tui::app`** gains one variant (`Stop`, D5) and is otherwise
untouched: MPRIS does not talk to the UI, it talks to the server, and
the UI learns the result the same way it learns about a keypress from
another client.
- **`crabidy-core`, `crabidy-server`**: unchanged. No proto change, no
server change.
## Risks
- **`zbus` feature unification.** `notify-rust` and `mpris-server` share
the crate; ours enables `zbus/tokio` so the connection runs on the
client's runtime instead of a second `async-io` reactor. If a future
`notify-rust` demanded `async-io` exclusively this would need
revisiting.
- **The published state is a broadcast behind.** Inherent to D3; bounded
by the 250 ms tick.
- **A status bar that polls `Position` sees 250 ms granularity.** No
interpolation is done, deliberately: the alternative is a local clock
that drifts against the server and lies while a track stalls.
- **The name goes away when the TUI exits**, mid-track and without
ceremony. Consumers handle `NameOwnerChanged`; this is the ordinary
lifecycle of a per-instance MPRIS player.
## Deferred
- **The `cbd-mpris` bridge** (Option C) — media keys without a TUI open.
- **`TrackList`** — the queue as an MPRIS track list. It is a real fit
(we have a queue with positions) but a third interface with its own
signals, wanted by few consumers.
- **`xesam:contentCreated`** from `Album.release_date`: the provider
strings are not all ISO 8601, and MPRIS wants a strict one.
- **`mpris:artUrl`** — needs cover art in the `Track` message first.

View File

@ -1,201 +0,0 @@
# Editable and deletable library nodes
## Context and problem statement
The search feature (`architecture/search.md`) introduced *creatable* nodes:
`%` under `/tidal/search` turns a typed term into a tree node holding search
results. Those nodes are currently immutable — a typo'd term can only be
abandoned, and stale terms accumulate for the lifetime of the server process.
This feature makes such nodes *modifiable*: in the library pane, `e` renames
the selected node (for a search term: re-runs the search under the new term)
and `d` deletes it. Modifiability is a per-node capability advertised by the
provider, exactly like `is_creatable` — the TUI never hardcodes which paths
support what.
Run autonomously per standing user instruction; every decision below records
the options considered and the rationale.
## Assumptions
- "Nodes that got created via `%`" are today exactly the search-term nodes
under `/tidal/search`; the design must not special-case them (playlists are
the obvious future candidate), but they are the only provider implementation
in this iteration.
- Editing means **renaming** (the title is the only user-supplied property a
node has). For a search term, the title *is* the query, so a rename re-runs
the search.
- `e`/`d` act on the **selected item in the library list** (the same selection
model as queueing), not on the currently-open node.
- Proto changes must stay wire-compatible (additive fields/rpcs only), as with
the search feature.
## Decisions
### D1 — Two capability flags, on the child only
Options considered:
1. One `is_modifiable` flag implying both rename and delete.
2. Two flags `is_editable` / `is_deletable`.
3. Flags on both `LibraryNodeChild` and `LibraryNode` (mirroring
`is_creatable`).
**Decision: (2), child-only** — `LibraryNodeChild.is_editable = 5`,
`LibraryNodeChild.is_deletable = 6`. Future node kinds plausibly support only
one of the two (a favorites entry may be deletable but not renamable), and an
extra bool costs nothing on the wire. Unlike `is_creatable` (consumed for the
*open* node: pane hint + `%` target), edit/delete are only ever checked
against the selected **child**, so node-level copies would have no consumer —
they are left out until something reads them.
### D2 — RPC shapes
Options considered:
1. One generic `UpdateLibraryNode` with optional fields + a separate delete.
2. `RenameLibraryNode(path, new_title) → node` and
`DeleteLibraryNode(path) → parent node`.
**Decision: (2).** Rename is the only edit that exists; a generic update
message would be speculative surface area. Both responses carry the node the
TUI should display next, following `CreateLibraryNode`:
- **Rename returns the renamed node** and the TUI navigates into it — the
same "show me the result" behavior as `%` create. The path changes on
rename (term is percent-encoded into the path), so returning the node is
also what tells the client the new path.
- **Delete returns the refreshed parent node** — the user is looking at the
parent listing when they press `d`; returning it saves a follow-up
`GetLibraryNode` and can never serve a stale cached listing.
### D3 — Provider semantics (tidaldy)
- `rename_lib_node(path, new_title)`: `path` must parse to
`TidalPath::SearchTerm`, else `NotSupported`. Title is trimmed; empty →
`InvalidInput`. The old term is **replaced in place** (keeps its position in
the terms list). Renaming to an already-existing term merges: the old entry
is removed, the existing one wins — the list never holds duplicates.
Renaming a term the server doesn't know (stale client cache, restart)
registers the new term — same forgiveness as `get_lib_node` on unknown
terms. Returns `get_lib_node(new_path)`.
- `delete_lib_node(path)`: `path` must parse to `TidalPath::SearchTerm`, else
`NotSupported`. Removing an unknown term succeeds silently — delete is
idempotent. Returns the refreshed parent node (`get_lib_node(/tidal/search)`).
- Both reuse the `search_terms` `RwLock` discipline: poison-tolerant, never
held across an await.
- **Queued search tracks survive rename/delete**: a queue entry
`/tidal/search/<enc-term>/<track-id>` resolves URLs/metadata from the
embedded track id alone; term registration is irrelevant to playback.
### D4 — No delete confirmation (for now)
**Options**: confirm prompt (`y`/`n` mini-mode) vs immediate delete.
**Decision: immediate.** The only deletable nodes are search terms, which are
free to recreate (`%` + retype); a confirmation mode adds a third input state
for no protected value. **Open question**: when higher-value nodes (user
playlists) become deletable, a confirmation step must be revisited — noted
here so the decision is rediscovered.
### D5 — TUI input overlay grows a purpose
`InputState` today hardcodes creation (`parent_path` + buffer). It becomes
```rust
InputState { purpose: InputPurpose, buffer: String }
enum InputPurpose { Create { parent_path }, Rename { path } }
```
- `e` opens the overlay **prefilled with the current title** (append-only
editing as before: chars push, Backspace pops); Enter sends
`MessageFromUi::RenameNode`, Esc cancels. Overlay label: `rename: <buffer>▏`
vs `new node: <buffer>▏`.
- Prefilling means "rename" degrades gracefully to "retype" — no cursor
movement is introduced in this iteration (matches the existing overlay).
- Submitting an unchanged title is sent anyway; the provider treats it as a
no-op rename and returns the node (harmless refresh).
- `d` sends `MessageFromUi::DeleteNode { path }` directly (D4).
- Both keys are silently ignored when the selected item lacks the flag,
mirroring `%` on non-creatable nodes.
- List marker: editable/deletable children render a `[e]`, `[d]` or `[ed]`
suffix in `COLOR_SECONDARY`, alongside the existing `[%]` for creatable
ones. The bindings table gains `e`/`d` in the Library scope (plain `e` and
`d` are unbound there today; `d` only exists in Queue scope), so the help
modal picks them up automatically.
### D6 — Client cache handling
`RpcClient` keeps `library_node_cache`. On rename: evict the **old path** and
the **parent**, insert the returned node under its new path. On delete: evict
the deleted path and the parent, insert the returned parent. (Same reasoning
as create's eviction — a stale `/tidal/search` listing would resurrect the
old term in the UI.)
### D7 — Server plumbing
Two new `ProviderCommand`s (`RenameLibraryNode`, `DeleteLibraryNode`) with the
established bounded(1) reply rendezvous; orchestrator routes `/tidal`-prefixed
paths to the tidal client and answers anything else `NotSupported`. gRPC error
mapping is identical to create: `NotSupported``failed_precondition`,
`InvalidInput``invalid_argument`, rest → `internal`; no internals leak into
`Status` messages.
## Flows
```d2
shape: sequence_diagram
user: { shape: person }
tui: cbd-tui
server: crabidy-server
tidal: tidaldy
user -> tui: "e on selected [ed] node"
tui -> tui: open overlay prefilled with title
user -> tui: edit text, Enter
tui -> server: RenameLibraryNode(path, new_title)
server -> tidal: rename_lib_node
tidal -> tidal: replace term in list (merge on collision)
tidal -> server: node at new path (fresh search)
server -> tui: renamed node
tui -> tui: evict old path + parent, show renamed node
user -> tui: "d on selected [ed] node"
tui -> server: DeleteLibraryNode(path)
server -> tidal: delete_lib_node
tidal -> tidal: remove term (idempotent)
tidal -> server: refreshed parent node
server -> tui: parent node
tui -> tui: evict path + parent, show parent listing
```
Capability flags travel with every listing:
```d2
direction: right
tidaldy: {
search_arm: "get_lib_node(/tidal/search)"
}
proto: "LibraryNodeChild { is_editable=5, is_deletable=6 }"
tui: {
list: "library list: title [ed]"
keys: "e -> rename overlay\nd -> DeleteNode"
}
tidaldy.search_arm -> proto: term children flagged
proto -> tui.list: render marker
proto -> tui.keys: gate actions
```
## Boundaries and risks
- **Proto**: additive only — two rpcs, two child fields (5, 6). Old clients
ignore the flags and never call the rpcs; old servers reject unknown rpcs
with `unimplemented` (tonic default), which the TUI logs without crashing.
- **Rename-to-collision** merges terms; the response node is the *existing*
term's node. The user sees the results they asked for either way.
- **Concurrent clients**: two TUIs editing the same term list race benignly —
the list is a `RwLock`-guarded Vec, every operation is atomic under the
write lock, and stale views self-heal on the next listing fetch.
- **Not in scope**: editing anything but the title; deleting non-search
nodes; confirmation UX (D4); persistent search terms (still per-process,
as shipped by the search feature).

View File

@ -1,242 +0,0 @@
# The `orphans` provider — a store garbage-collection view
## Context and problem statement
The content-addressed store (`architecture/crabidy-store.md`) never shrinks on
its own. Capturing writes audio + a `<name>.cbd-store.toml` sidecar into
`~/.local/share/crabidy/`; deleting a save (or the `current` queue rolling over,
or a `scan --capture` toml being removed) only ever removes the *toml that
pointed at* a store entry — never the entry itself (store `D7`). That is
deliberate: a store entry may be shared by many tomls, so no single deletion can
know it is safe to reclaim. The consequence, called out as future work in store
`D10`, is that store entries accumulate that **no toml references any more**.
There is today no way to see them or reclaim their disk.
This design adds a read-mostly management provider, **`/orphans`**, that surfaces
exactly those unreferenced store entries and lets the user rename or delete them,
or queue them for a listen before deciding. It is the store's garbage-collection
UI, expressed as an ordinary library subtree so it needs no new client concepts.
This **realizes** store `D10` (orphan reclamation). It changes nothing about how
captures are written or de-duplicated; it only *reads* the store's residue and
offers targeted rename/delete.
## Assumptions
- **Confirmed by the request.** `/orphans` lists every store item, walks all
local file providers, crosses off referenced items, and presents the rest;
entries are renamable (audio file *and* sidecar), deletable (files on disk),
and queueable.
- **"Referenced" means reachable through a mounted local file provider.** The
reference scan walks the disk roots of the running file providers — the
`/crabidy` toml tree (`~/.local/state/crabidy/`, which holds `current`, every
save, every capture) and the `/fs` root (which can hold `Playable::Store`
tomls written by `scan --capture`/`--move`). A `.cbd-track.toml` that lives
*outside* every mounted provider root (e.g. `scan --capture` run on a folder
that is not under `/fs`) is invisible to the walk, so its target counts as an
orphan. This is the only sound definition available without a global
reference index, and it matches the request's wording ("walks all local file
providers"). It is a documented boundary, not a bug (see Risks).
- **Single writer.** As with the rest of the store, one process owns both roots
and serializes mutations under the store's index mutex; there is no concurrent
external writer.
- **Orphan-ness is recomputed on every listing.** There is no persisted orphan
list — consistent with `fsdy`'s "read the tree fresh every visit" philosophy.
A capture that adds a reference makes an entry stop being an orphan on the
next listing.
## What is a "store item" and when is it an orphan
- A **store item** is a pair in `store_root`: an audio file `<name>` and its
sidecar `<name>.cbd-store.toml`. The sidecar is the source of truth (store
`D2`); the set of items is the set of sidecars that have a readable audio file
beside them. (A sidecar without audio, or audio without a sidecar, is
malformed residue — reported so it can be reclaimed too; see D4.)
- A store item `<name>` is **referenced** iff some `.cbd-track.toml` under a
mounted file-provider root validates to `Playable::Store(<name>)`.
- **Orphans = all store items referenced store items.**
Because `/orphans` only ever exposes unreferenced entries, renaming or deleting
one **cannot break any toml reference** — that is what makes the destructive
operations safe by construction (subject to the narrow race in Risks).
## Options considered
### Presentation: tracks vs. child nodes
Each orphan must be renamable, deletable, and queueable. The wire has two
carriers with capability flags:
- **`Track`** — has `is_captured`/`is_skipped` but **no** `is_editable`. Tracks
can be deleted (`tracks_deletable`) and queued, but the library has **no
rename-a-track gesture** anywhere; adding one means new proto surface plus TUI
and web changes.
- **`LibraryNodeChild`** — already carries `is_editable`, `is_deletable`,
`is_queable`, `is_captured`. The node-editing feature already binds `e`
`rename_lib_node(child_path, new_title)` and `d``delete_lib_node(child_path)`
for children that advertise the flags (that is how `/tidal/search` terms and
`/crabidy` saves are renamed/deleted today), and queueing a queueable child
resolves its tracks.
**Decision: present each orphan as an editable + deletable + queueable child
node** of `/orphans`, titled by its store file `<name>` (the thing a rename
edits). Entering the node lists its single track (the store audio, with metadata
from the sidecar); queueing the node — or the whole `/orphans` root — resolves
that track. This reuses `rename_lib_node`/`delete_lib_node`/`resolve_tracks_into`
and every client gesture **with zero proto, TUI, or web changes**. The only
minor wart — each orphan is a one-track "folder" — is acceptable and is exactly
how a single-track save already presents. The track-carrier option was rejected
purely on cost: it buys nothing the node model lacks and forces a wire change
just to gain a rename gesture.
### Home of the logic: new crate vs. server module
The orphan computation needs the store root and index (to enumerate items and
mutate them) *and* the file-provider disk roots (to find references). A standalone
`orphandy` crate would have to duplicate `CrabidyStore` internals it does not own.
**Decision: keep it in `crabidy-server`.** Orphan enumeration, rename, and delete
become methods on `CrabidyStore` (it already owns `store_root` and the index). A
thin new `OrphansProvider` (`crabidy-server/src/orphans.rs`) implements
`ProviderClient`, holding `Arc<CrabidyStore>` plus the list of reference roots to
walk, and delegates to those store methods. `ProviderOrchestrator` mounts and
routes `/orphans` exactly like the other providers.
## Boundaries and interfaces
```d2
direction: right
tui: TUI / web / cbd-cli { shape: person }
orchestrator: ProviderOrchestrator {
routes: "routes /orphans/*"
}
orphans: OrphansProvider {
refroots: "ref_roots: Vec<PathBuf>"
}
store: CrabidyStore {
index: "StoreIndex (by_hash / by_provider_id)"
ops: "list_orphans / rename_orphan / delete_orphan"
}
data: content store\n~/.local/share/crabidy {
shape: cylinder
items: "<name> + <name>.cbd-store.toml"
}
crabidytree: /crabidy tree\n~/.local/state/crabidy { shape: cylinder }
fstree: /fs root { shape: cylinder }
tui -> orchestrator: "get / rename / delete / queue /orphans/*"
orchestrator -> orphans: delegate
orphans -> store: "enumerate + mutate (by name)"
store -> data: "read sidecars, rename/delete files"
orphans -> crabidytree: "walk for Playable::Store refs"
orphans -> fstree: "walk for Playable::Store refs"
```
### The orphan diff (what a listing computes)
```d2
direction: down
allitems: "all store items\n(scan *.cbd-store.toml in store_root)"
refs: "referenced set\n(walk ref_roots for\nPlayable::Store(name))"
diff: "orphans = all referenced" { shape: diamond }
node: "/orphans node:\none editable/deletable/queueable\nchild per orphan"
allitems -> diff
refs -> diff
diff -> node
```
### Provider surface (`OrphansProvider: ProviderClient`)
Mounted at `/orphans` only when `crabidy_store` is present (it is the store's
view). Paths: the root `/orphans`, and one child per orphan at
`/orphans/<encode_segment(name)>`. There are no deeper levels.
- `get_lib_root` / `get_lib_node("/orphans")` — a queueable, non-creatable node
whose children are the current orphans (recomputed by the diff above). Each
child: `title = <name>`, `is_queable = true`, `is_editable = true`,
`is_deletable = true`, `is_downloadable = false`, `is_captured = true`.
- `get_lib_node("/orphans/<seg>")` — a queueable, childless node carrying the
single `Track` for that store entry (metadata from the sidecar's first
provider entry; `is_captured = true`). Unknown/renamed-away segment →
`MalformedPath`.
- `is_track_path` — always `false`: orphans are addressed as nodes, and the one
track is reached by resolving the node (so the default `resolve_tracks_into`
walk queues it). `get_metadata_for_track` therefore is not the entry point;
`get_urls_for_track("/orphans/<seg>")` returns `store_root/<name>` (a local
file path, exactly like a resolved `Playable::Store`) so the resolved track
still plays.
- `rename_lib_node("/orphans/<seg>", new)` — validates `new` as a bare store
file name (`validate_folder_name(new, &[])`: non-empty, no separators/NUL, no
leading dot), refuses a name already taken by another store entry
(`InvalidInput`), then renames **both** `<old>`→`<new>` audio and
`<old>.cbd-store.toml`→`<new>.cbd-store.toml`, and updates the in-memory index
(drop the old name's mappings, re-insert under the new name; hash and provider
ids are unchanged). Returns the renamed node at `/orphans/<encode(new)>`.
- `delete_lib_node("/orphans/<seg>")` — removes the audio file and the sidecar
from `store_root` and drops the entry from the index; returns the refreshed
`/orphans` root. Idempotent (an already-gone entry succeeds).
- `create_lib_node``NotSupported` (the root is not creatable).
### Store methods added to `CrabidyStore`
- `list_orphans(&self, ref_roots: &[PathBuf]) -> Result<Vec<OrphanEntry>, StoreError>`
— scan `store_root` for `*.cbd-store.toml`; build the referenced set by walking
each `ref_root` recursively for `*.cbd-track.toml` and collecting
`Playable::Store(name)`; return the difference as `OrphanEntry { name, title,
artist, duration, album }` (metadata from the sidecar's first provider entry).
- `orphan_track(&self, name) -> Result<Track, StoreError>` /
`orphan_url(&self, name) -> Result<String, StoreError>` — build the wire track
/ resolve the store audio path for a single entry.
- `rename_orphan(&self, old, new)` / `delete_orphan(&self, name)` — the mutations
above, under the index mutex, with the index kept in sync.
- `StoreIndex::remove(&mut self, name, sidecar)` — the inverse of `insert`, so
rename/delete can update the derived index without a full rescan.
### Reference roots wiring
`OrphansProvider` is constructed in `ProviderOrchestrator::init` with
`ref_roots` = the disk roots of the mounted file providers: the `/crabidy` tree
(`store.tree_dir()`) and, when enabled, the `/fs` root. A new
`fsdy::Client::disk_root(&self) -> &Path` accessor exposes the `/fs` root (the
`/crabidy` tree root is already available via `CrabidyStore::tree_dir`). If more
`fsdy` instances are ever mounted, they are added to this list — the definition
of "local file provider" is "an `fsdy` instance whose root can hold store
references."
## Risks and open questions
- **Capture-then-delete race (TOCTOU).** Between a `/orphans` listing and a
delete, a concurrent `W` capture could hash-hit the very entry the user is
about to delete and write a fresh `Playable::Store` reference to it; deleting
then leaves that new toml dangling. The window is small (store mutations
serialize under the index mutex and orphan-ness is recomputed every listing),
and the failure is benign: a dangling store reference already resolves to
`MalformedPath` at play time and is skipped, not a crash. Accepted; noted here
rather than engineered away.
- **References outside mounted roots are not counted.** As stated in Assumptions,
a `scan --capture` toml under a folder that is not mounted under `/fs` will not
be seen, so its target shows as an orphan. Deleting it would orphan that
toml's audio. The mitigation is scope discipline (scan under `/fs`); the
alternative — a persisted global reference index — is out of scope and would
fight the "read fresh" design.
- **Cost.** A `/orphans` listing scans the whole store plus both trees on every
visit (no cache), i.e. O(store entries + tomls under the roots). This matches
`fsdy`'s existing per-visit read cost and is fine for personal-library sizes;
if it ever bites, memoizing behind the index's mutation counter is the escape
hatch. Not premature-optimized here.
- **Malformed residue.** A sidecar with no audio (or vice versa) is itself
reclaimable junk. `list_orphans` reports such half-entries as orphans (titled
by whatever is present) so a delete cleans them up; it never treats a
half-entry as "referenced."
- **Open question:** should the `/orphans` root also expose a single "delete all"
affordance? Deferred — per-item delete covers the request; bulk reclaim can be
a later addition (a client could multi-select and delete, once marks exist
there).

View File

@ -1,415 +0,0 @@
# Crabidy architecture overview
Status: as-built, describing the workspace at commit `634b89b` (2026-07-20).
This document records the architecture of the existing system rather than
proposing a new one. Where a design decision is load-bearing, the alternatives
that were genuinely live are recorded alongside it, so a future change can see
what was traded away.
## Context and problem statement
Crabidy plays music from a streaming provider (currently Tidal) on the machine
that runs the server, and is driven from a terminal UI that may run elsewhere.
The split exists because the audio hardware and the user are not necessarily in
the same place: the server owns the sound device, the queue, and the provider
credentials; clients are thin and disposable.
The problems the design has to solve:
- **Long-lived playback must survive provider failures.** A track that cannot
be fetched, an expired OAuth token, or a queue edit racing with a track
transition must not silently kill playback. This was the dominant historical
bug class.
- **The library is a remote, paginated, slow tree.** Browsing must not block
playback, and playback must not block browsing.
- **Multiple clients observe one shared state.** Queue, play state, volume and
position changes have to reach every connected client.
- **Traces must be attributable.** A command crossing a channel loses its
caller's span by default, which previously made the logs actively misleading.
## Assumptions
These are the assumptions the current design rests on. They were inferred from
the code and the deployment shape, not confirmed in discussion — the ones that
would most change the design if wrong are flagged.
1. **One server instance, one audio device, one user.** There is no
multi-tenancy, no per-client playback, and no authentication on the gRPC
surface. **Load-bearing:** the server binds `0.0.0.0:50051` unauthenticated,
so it assumes a trusted network.
2. **Clients are untrusted only in the sense of being buggy, not hostile.**
Paths from clients are validated for shape but the server will happily act
on any well-formed path.
3. **State is ephemeral.** The queue lives in memory and dies with the process.
`SaveQueue` exists in the proto but is a stub.
4. **Provider count will grow.** The path scheme and the `ProviderClient` trait
are built for more than one provider, though only Tidal is implemented.
5. **The provider is slow and unreliable; the audio device is fast and
reliable.** Timeouts, retries and error recovery are concentrated on the
provider side.
## Component structure
Five crates, split so that the wire contract and the provider abstraction do
not depend on either the server or the client.
```d2
direction: right
core: crabidy-core {
explanation: |md
proto (tonic/prost)
ProviderClient trait
path helpers
|
}
server: crabidy-server {
explanation: |md
gRPC service
playback + provider loops
QueueManager
|
}
tidaldy: tidaldy {
explanation: |md
Tidal REST client
OAuth device flow
path parsing
|
}
audio: audio-player {
explanation: |md
rodio wrapper
engine thread
|
}
tui: cbd-tui {
explanation: |md
ratatui client
|
}
server -> core: proto + trait
tidaldy -> core: implements ProviderClient
server -> tidaldy: owns instance
server -> audio: commands
tui -> core: proto only
tui -> server: gRPC over TCP
```
`crabidy-core` is deliberately dependency-light: it holds the generated proto
types, the `ProviderClient` trait, and the path helpers (`ROOT_PATH`,
`parent_path`, `join_path`, `path_segments`). Both the server and the TUI
depend on it, which is what keeps the wire contract in one place.
## Runtime structure
Inside the server process there are three concurrency domains, connected by
channels rather than shared locks.
```d2
direction: down
grpc: tonic gRPC server {
handlers: RPC handlers
stream: GetUpdateStream
}
playback: Playback loop {
explanation: |md
tokio task
owns QueueManager
owns PlayState
|
}
provider: ProviderOrchestrator loop {
explanation: |md
tokio task
routes by path prefix
|
}
bridge: poll_play_bus {
explanation: |md
OS thread
PlayerMessage -> PlaybackCommand
|
}
engine: PlayerEngine {
explanation: |md
dedicated OS thread
rodio sink + decoder
|
}
tidal: Tidal REST API
device: Audio device
grpc.handlers -> playback: PlaybackMessage\nflume bounded(64)
grpc.handlers -> provider: ProviderMessage\nflume bounded(100)
playback -> provider: ResolveTracks / GetTrackUrls\nbounded(1) reply
provider -> tidal: HTTPS, 30s timeout
playback -> engine: PlayerEngineCommand\nflume bounded(16)
engine -> bridge: PlayerMessage
bridge -> playback: PlaybackCommand
engine -> device: PCM
playback -> grpc.stream: StreamUpdate\ntokio broadcast(2048)
```
Three properties of this layout matter:
**The playback loop is the single writer of playback state.** `QueueManager`
and `PlayState` sit behind mutexes inside `Playback`, but only the loop's own
handlers touch them, and no lock is ever held across an `await`. The mutexes
exist because `handle_command` takes `&self`, not because of contention.
**The audio engine is a blocking OS thread, not a tokio task.** rodio's sink is
synchronous and the decoder does real CPU work, so it would starve the async
runtime. The engine loop uses `recv_timeout(250ms)`: a command wakes it,
otherwise the timeout fires and it emits an elapsed-position tick. It captures
a `tokio::runtime::Handle` at construction so it can `block_on` the HTTP stream
open, and falls back to building its own single-worker runtime if constructed
outside a tokio context.
**End-of-stream is a callback, not an error.** After the decoder, the engine
appends a `rodio::source::EmptyCallback` carrying a generation number. It fires
only when the decoder ahead of it drains naturally; `reset()` bumps the
generation, so an end-of-stream from a track that was already replaced is
recognised as stale and dropped. This replaced an earlier scheme that detected
end-of-stream by string-matching an `io::Error` message, which lost the signal
whenever the underlying error was anything else — the track would end and
nothing would advance the queue.
## Path addressing
Library nodes and tracks are addressed by filesystem-like absolute paths whose
first segment names the provider. The path encodes the position in the tree, so
a parent is obtained by trimming a segment and no separate parent lookup is
needed.
```d2
direction: right
root: "/" {
shape: page
}
tidal: "/tidal"
playlists: "/tidal/playlists"
playlist: "/tidal/playlists/<uuid>"
ptrack: "/tidal/playlists/<uuid>/<track-id>" {
style.fill: "#e8f4e8"
}
artists: "/tidal/artists"
artist: "/tidal/artists/<id>"
album: "/tidal/artists/<id>/<album-id>"
atrack: "/tidal/artists/<id>/<album-id>/<track-id>" {
style.fill: "#e8f4e8"
}
root -> tidal
tidal -> playlists
playlists -> playlist
playlist -> ptrack
tidal -> artists
artists -> artist
artist -> album
album -> atrack
```
Green nodes are track paths. Whether a path is a track or a node is decided by
its **shape**, not by a type tag: `tidaldy::parse_path` matches the segment
slice against a fixed set of variants (`TidalPath::PlaylistTrack`,
`AlbumTrack`, …) and `is_track_path` returns true for exactly the two track
variants. A path that matches no variant is rejected as `MalformedPath` rather
than being guessed at.
Routing is by prefix. `ProviderOrchestrator` serves `/` itself (a synthetic
root whose only child is `/tidal`) and delegates anything under `/tidal` to the
Tidal client. Adding a provider means adding a prefix arm and a
`ProviderClient` implementation.
The same path is the unit of queueing. `ResolveTracks` takes any path and
flattens it: a track path yields one track, a node path is walked breadth-first
over its queueable descendants. So "queue this playlist" and "queue this track"
are the same RPC with a different path.
### Trade-off: paths versus opaque ids
The previous scheme used opaque ids (`node:playlist:<id>`, `track:<id>`). Paths
were chosen over keeping ids because the tree position is the thing the UI
actually needs — breadcrumbs, "go up", and knowing where a track came from all
fall out of the path for free, and `LibraryNode.parent` becomes derivable
rather than something the provider must remember to populate.
What this costs: a track's identity is now context-dependent. The same Tidal
track reached through a playlist and through an album has two different paths,
so de-duplication and "is this the track I'm already playing" comparisons
cannot be done by path equality alone. That has not bitten yet because the
queue stores resolved `Track` values, but it is a real constraint on any future
feature that needs stable track identity.
The proto migration kept field numbers and only renamed `uuid``path` and
`uuids``paths`, so the change was wire-compatible.
## Tracing model
Commands cross channels, and a channel breaks the span parent-child
relationship: by default the handler's events attach to whatever span happened
to be current on the consumer task, which produced logs that attributed work to
unrelated requests.
Every message is therefore an envelope pairing the command with the sender's
span:
```rust
pub struct PlaybackMessage { pub span: Span, pub command: PlaybackCommand }
impl PlaybackMessage {
pub fn new(command: PlaybackCommand) -> Self {
Self { span: Span::current(), command }
}
}
```
The consumer creates a child of the captured span and instruments the whole
handler future:
```rust
let handler_span = debug_span!(parent: &span, "playback_command", command = command.name());
self.handle_command(command).instrument(handler_span).await;
```
Instrumenting the *future* (rather than taking an `enter()` guard) is the part
that matters — a guard held across an `await` attributes the time another task
runs to this span.
The `name()` methods on `PlaybackCommand` and `ProviderCommand` exist to give
the span a low-cardinality label. Paths and positions are recorded as span
fields instead, so per-command spans stay groupable.
Server logs go to stderr through a non-blocking writer; the TUI cannot share
stderr with its own rendering, so it logs to a rolling daily file under the
state directory. Both honour `RUST_LOG` and default to debug for the workspace
crates, info for everything else.
## Provider authentication
Tidal uses the OAuth 2.0 device authorization grant (RFC 8628). The client is
constructed, then `init` tries `login_config` (reuse stored tokens) and falls
back to `login_web` (print a link, poll until authorized).
```d2
shape: sequence_diagram
server: crabidy-server
tidaldy: tidaldy::Client
auth: auth.tidal.com
user: User { shape: person }
server -> tidaldy: init(toml)
tidaldy -> auth: POST /device_authorization
auth -> tidaldy: device_code, verification_uri, interval
tidaldy -> user: print link
loop: {
tidaldy -> auth: POST /token (secret in body)
auth -> tidaldy: 400 authorization_pending
}
user -> auth: authorizes in browser
tidaldy -> auth: POST /token
auth -> tidaldy: access_token, refresh_token, expires_in
tidaldy -> server: Ok(Client)
```
Two details are non-obvious and were the cause of real outages:
**The client secret travels in the request body, not HTTP Basic auth.** Tidal's
edge rejects Basic auth on `/oauth2/token` with a 403 HTML page. Because the
old poll loop treated every non-success response as "not yet authorized", that
403 was indistinguishable from a normal pending poll, and login appeared to
hang silently for the full five-minute window regardless of whether the user
authorized. The loop now classifies outcomes explicitly — `authorization_pending`
keeps polling, `slow_down` backs off by five seconds per RFC 8628, transport
errors retry, anything else aborts with the provider's actual error text.
**Access tokens are refreshed proactively.** `ensure_fresh_token` refreshes when
the token expires within five minutes; `make_request` additionally retries once
on a 401 in case the token was revoked or the clock is off. Without this, a
session that outlived its token failed every fetch, and playback died at the
next track boundary with no obvious cause.
Login state lives behind a `std::sync::RwLock` because the client is shared
immutably while tokens change at runtime. The lock is never held across an
await — a snapshot is cloned out, the request runs, then the result is stored.
## Boundaries and interfaces
**Client ↔ server** — gRPC `CrabidyService`. Request/response, except
`GetUpdateStream`, which is server-streaming.
**RPC → playback** — `flume::bounded(64)` of `PlaybackMessage`.
Fire-and-forget, except `Init`, which carries a reply channel.
**RPC and playback → provider** — `flume::bounded(100)` of
`ProviderMessage`. Every command carries its own `bounded(1)` reply
channel.
**Playback → clients** — `tokio::sync::broadcast(2048)`. Lossy by design:
a slow client gets `Status::data_loss` and must resubscribe.
**Playback ↔ engine** — `flume::bounded(16)` in both directions. Commands
carry reply channels; events flow back unsolicited.
**Server ↔ provider API** — HTTPS with a 30-second timeout. Failures are
classified into `ProviderError`.
The broadcast channel is the one place where delivery is deliberately lossy,
and that is surfaced rather than hidden: a lagging subscriber receives an
explicit `data_loss` status instead of quietly missing updates.
Reply channels are `bounded(1)` and awaited by the caller, which makes each
request/reply pair a rendezvous. This is why the provider loop must never block
indefinitely — the 30-second HTTP timeout is what bounds it.
## Risks and known gaps
- **No authentication or transport security.** The gRPC surface is open on
`0.0.0.0:50051`. Fine on a trusted LAN, unsuitable for anything else.
- **The TUI uses unbounded channels** between its UI thread and its RPC task,
which contradicts the workspace's bounded-channel rule. The UI produces
events at human speed so it has not caused problems, but it is unbounded
buffering on a path that can block.
- **The TUI's `orchestrate` task `unwrap`s.** A connection failure after
startup panics that task rather than surfacing an error in the UI.
- **Refreshed tokens are not persisted.** `tidaly.toml` is written once at
startup; tokens refreshed during a session are lost on restart, forcing a new
device login more often than necessary.
- **Mute is unimplemented.** `ToggleMute` is accepted and logged, and the
`MuteChanged` update exists, but the engine has no mute.
- **`SaveQueue` is a stub** that returns success without saving.
- **Single provider.** The abstraction is there but has only one implementation,
so its fit is unproven.
- **The queue is memory-only.** Process restart loses it.
## Open questions
1. Should track identity be decoupled from tree position, or is
context-dependent identity acceptable long-term? This decides whether
de-duplication and "already playing" checks are feasible.
2. Does the queue need persistence, and if so is `SaveQueue` the right shape —
named saved queues, or a single autosaved session?
3. Should the server authenticate clients, or is the trusted-network assumption
permanent?
4. When a second provider lands, does prefix routing stay in
`ProviderOrchestrator`, or should providers register themselves?
## Next stage
This feeds `api-design` (stage 2). The most likely candidates for API work are
queue persistence and whatever contract a second provider needs.

View File

@ -1,319 +0,0 @@
# Progressive queueing of large collections
## Context and problem statement
Queueing a nested node (an artist with many albums, a large playlist) today
freezes the UI's mental model: nothing changes for many seconds, then the full
queue appears at once. Three compounding causes, all in the resolve path:
1. `Playback::resolve_tracks` collects **every** track before the queue is
touched, so the single `Queue` broadcast happens only at the very end and
playback cannot start earlier.
2. The resolve runs **inline in the playback loop**, so every other playback
command — pause, next, volume — is blocked for the duration.
3. `tidaldy` paginates collections to exhaustion (50 tracks per sequential
request) inside one `get_lib_node` call, so even a single large playlist
produces no intermediate result.
The feature: resolve progressively. Tracks are applied to the queue in chunks
as the provider produces them, each chunk is broadcast, playback starts with
the first chunk, and clients see a loading indicator (animated dots as a
pseudo last queue item) while resolution is still running.
Run autonomously per standing user instruction; every decision below records
the options considered and the rationale.
## Assumptions
- Chunks must arrive **in playback order** — the user explicitly wants "the
first elements first, and later chunks follow". Order is known up front
(collection order), so no reordering step is needed.
- The four resolve-based queue operations (`Replace`, `Queue`, `Append`,
`Insert`) all benefit equally and should share one mechanism.
- Wire changes must stay additive (old clients keep working; they simply see
the queue fill progressively without an indicator).
- Multi-client remains supported: the indicator must be server-derived state,
not client-local guessing.
## Decisions
### D1 — End-to-end progressive resolution, not a client-only spinner
Options considered:
1. **Client-only indicator**: the TUI shows dots between sending a queue op
and receiving the next `Queue` update. No wire or server change.
2. **Server-side chunked resolution** with a wire-visible "still resolving"
flag; the indicator falls out of the flag.
**Decision: (2).** Option 1 papers over the latency without fixing it —
playback would still start only after the full resolve, other clients would
see nothing, and the "did it even register?" dead time remains. Option 2
fixes the actual complaint (start playing early, fill visibly) and gives
every client the indicator for free. The server broadcasts an immediate
`Queue` update (unchanged tracks, `resolving = true`) when the op is
accepted, so feedback appears within one round trip.
### D2 — `Queue.resolving` field, not a new stream-update variant
Options considered:
1. New `GetUpdateStream` oneof variant `resolving(bool)`.
2. New field `bool resolving = 4` on the `Queue` message itself.
**Decision: (2).** The flag is queue state and must be atomic with the track
snapshot it describes; a separate variant can arrive out of order relative to
`Queue` updates (the broadcast channel is lossy for slow clients). Additive
field, wire-compatible both ways: old clients ignore it, old servers never
set it.
### D3 — Chunked resolution lives in `ProviderClient`, with a default
Options considered:
1. Keep the BFS in `ProviderOrchestrator` (chunk = one node's tracks); no
trait change. A 1000-track playlist is still one 20-request blob.
2. Add a chunked resolve method to the `ProviderClient` trait with a default
implementation (the generic walk, one chunk per node); `tidaldy`
overrides it to stream **page-sized chunks (50)** for the paginated
collections (playlists, albums).
**Decision: (2).**
```rust
/// Streams the playable tracks under `path` into `chunk_tx` in playback
/// order. Zero or more chunks, then the sender is dropped: a dropped
/// SENDER means resolution finished. A dropped RECEIVER cancels
/// resolution (the provider stops fetching and returns Ok).
async fn resolve_tracks_into(
&self,
path: &str,
chunk_tx: flume::Sender<Vec<Track>>,
) -> Result<(), ProviderError>;
```
The channel semantics are the contract and are documented on the trait —
this is a local bounded channel used as a stream, not a queue pretending to
be durable. Unreadable nodes are skipped with a warning (today's behavior);
only a completely unresolvable root returns an error. The default
implementation walks the tree **depth-first pre-order** over queueable
descendants, emitting one chunk per node. This replaces the old orchestrator
BFS, and fixes a latent ordering bug while doing so: the old worklist popped
LIFO, so an artist's albums were flattened in *reverse* order.
`tidaldy` overrides the method: playlist and album paths stream one chunk
per fetched page instead of paginating to exhaustion first (a new
`make_paginated_request` variant hands each page to a sink); everything else
follows the generic walk. Search-term track lists are a single page already.
The playlist arm also stops fetching the playlist metadata (title) — the
resolve needs only tracks.
`flume` is already a workspace dependency; `crabidy-core` adopts it for the
trait signature.
### D4 — Neither event loop blocks: spawned resolves, single-writer queue
Options considered:
1. Consume chunks inline in the playback loop's `handle_command` (loop still
blocked for the whole resolve; pause/next dead — today's hidden defect).
2. Spawn the resolve; queue mutations travel back to the playback loop as
internal commands, so the loop stays the **single writer** of queue state.
**Decision: (2), on both loops.**
- **Provider side**: `ProviderOrchestrator::run` wraps the orchestrator in an
`Arc`; the `ResolveTracks` arm spawns the resolve onto its own task instead
of awaiting it inline. Without this, the first chunk would deadlock the
system: playback applies chunk 1 → `play()` → sends `GetTrackUrls` and
awaits the reply — but the provider loop would still be busy resolving.
Other provider commands keep flowing while (possibly several) resolves run.
- **Playback side**: each queue op registers a *pending op* (id from an
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
drives `ProviderCommand::ResolveTracks` per path (concurrently, under the
exponential read-ahead window of D8, but forwarding chunks in strict
multi-path order) and forwards each chunk to the playback channel as
`PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by
`ResolveFinished { op_id }`. Queue state is only ever mutated inside the
loop, exactly as before; user commands interleave between chunks.
Backpressure is real at every hop: provider → forwarder over a small bounded
chunk channel, forwarder → playback over the existing `bounded(64)` command
channel. A slow consumer slows the HTTP fetching down instead of buffering
unboundedly.
### D5 — Chunk application semantics per op kind
Each pending op keeps an insertion cursor:
- **Replace**: first chunk `replace_with_tracks` (broadcast resets the
queue, current position 0, playback starts); later chunks append.
- **Append**: every chunk `append_tracks`.
- **Queue** (after current): cursor starts at the current position; each
chunk `insert_tracks(cursor)`, then `cursor += chunk.len()`.
- **Insert**: same, starting at the requested position.
Playback start reuses the existing `Option<Track>` returns from the
`QueueManager` mutations — only a chunk that makes a track current (replace,
or any insert into an empty queue) yields one, so exactly the first relevant
chunk starts the player and later chunks never restart a playing track.
That first start can *fail to find anything playable yet*: if the head
tracks are `is_skipped` or the collection's first track is very short, the
player can run dry before the next chunk resolves — and then, since later
chunks return `None`, playback would stay stopped with playable tracks
arriving right behind it. So the op carries a `wants_start` flag: set when a
chunk makes a track current, cleared only once a start is *confirmed*
(`play` returned that it handed a track to the player). While it is set, each
arriving chunk retries the start from the current position — `next_playable`
advances past skipped/unplayable heads to the first track that has now
resolved. The exponential read-ahead (D8) makes that next track arrive
sooner; the retry makes sure it actually plays when it does. An op whose
whole resolve finishes with nothing playable is dropped by `finish_resolve`
and the player simply stays stopped.
Interleaved edits from other clients during a resolve can shift the cursor's
target (e.g. removing tracks before it). This is accepted as benign:
`insert_tracks` already clamps, the queue self-heals on the next broadcast,
and simultaneous multi-client edits during a resolve are rare. Under
shuffle, arriving chunks are shuffled behind the current track like any
other insert — chunk order is irrelevant when shuffle is on. Each op counts
its applied tracks; an op that finishes with zero keeps today's
"resolved to no playable tracks" warning.
### D6 — `Replace` and `Clear` cancel in-flight resolves
Without cancellation, "replace the queue" or "clear the queue" during a
large resolve would be followed by the old op's remaining chunks trickling
back in — a corrupted queue, and the exact ghost behavior this feature is
meant to kill. Options: let stale chunks land (wrong), or cancel.
**Decision: cancel.** Each pending op carries an `Arc<AtomicBool>` shared
with its forwarder. `Replace` and `Clear` mark every pending op cancelled
and drop it from the map. The forwarder checks the flag per chunk and, when
set, drops the chunk receiver — the provider's next `send` fails and the
resolve task stops fetching (the documented receiver-drop semantics from
D3). Chunks already in flight for an unknown op id are ignored by the loop.
`Queue`/`Append`/`Insert` do **not** cancel: concurrent additive ops are
legal; their chunks interleave between ops while each op's internal order is
preserved.
### D7 — TUI indicator: an animated pseudo-item, outside the list model
While the latest `Queue` update carries `resolving = true`, the queue pane
renders one extra line after the last track: one to three dots cycling
(~400 ms per step, derived from elapsed time — the render loop already
redraws at least every 100 ms), in `COLOR_SECONDARY`. The pseudo-item is
appended at render time only and never enters `self.list`, so selection,
removal, and `get_size` cannot reach it — no new input states, nothing to
misclick.
### D8 — Exponential read-ahead across paths
The forwarder of D4 first resolved paths one at a time. That starts the first
track quickly (good) but fills the rest only as fast as one provider resolve
at a time. When enumeration is slow (per-path Tidal/YouTube/fyyd round trips)
and the leading tracks are very short or `is_skipped`, playback drains the
resolved queue faster than a sequential resolver refills it — and stalls into
silence, the exact thing progressive queueing exists to avoid.
Options considered:
1. **Sequential per path** (original): simplest, but a short/skipped head can
outrun a slow resolver.
2. **Resolve every path at once**: fills fastest, but a large marked
selection fires an unbounded burst of concurrent provider calls (rate
limits, memory) the instant playback starts — wasteful when the user skips
away after two tracks.
3. **Exponential read-ahead window**: a concurrency window that starts at 1
and doubles (1, 2, 4, 8, 16, then steady 16) after each path completes.
**Decision: (3).** The first path resolves alone, so time-to-first-track is
unchanged from (1); the window then grows geometrically, so the resolved
queue runs exponentially ahead of linear playback and a short/skipped head
cannot catch it after the first couple of tracks. The cap (16) bounds
concurrent provider load. Chunks are still forwarded in strict path order —
the forwarder fully drains the oldest in-flight resolve before the next, so
concurrency never reorders the queue (D5's per-op cursor and "first chunk
starts the player" are untouched). Cancellation (D6) drops the in-flight
receivers, stopping every concurrent resolve at once.
This is a read-ahead over *paths*. A single collection path (one album or
playlist) is still enumerated by its provider's `resolve_tracks_into` — the
per-page streaming of D3 is that path's read-ahead — so the window is the win
for multi-item selections; single-collection latency stays a provider concern.
## Flows
```d2
shape: sequence_diagram
user: { shape: person }
tui: cbd-tui
rpc: gRPC handler
playback: playback loop
fwd: forwarder task
provider: provider loop
resolve: resolve task
tidal: Tidal API
user -> tui: queue large artist
tui -> rpc: Append(paths)
rpc -> playback: "PlaybackCommand::Append (fire-and-forget)"
playback -> playback: register pending op
playback -> tui: "Queue update (resolving=true)"
playback -> fwd: spawn
fwd -> provider: "ResolveTracks(path, chunk_tx)"
provider -> resolve: spawn
resolve -> tidal: fetch page 1
resolve -> fwd: chunk 1
fwd -> playback: "ApplyResolvedChunk(op, chunk 1)"
playback -> tui: "Queue update (resolving=true)"
playback -> playback: "play() first track"
resolve -> tidal: fetch page 2
resolve -> fwd: chunk 2
fwd -> playback: "ApplyResolvedChunk(op, chunk 2)"
playback -> tui: "Queue update (resolving=true)"
fwd -> playback: "ResolveFinished(op)"
playback -> tui: "Queue update (resolving=false)"
```
The dots pseudo-item is visible in the TUI exactly while updates carry
`resolving = true`; user commands (pause, next, remove) flow through the
playback loop between chunk applications instead of waiting for the end.
```d2
direction: right
core: "ProviderClient::resolve_tracks_into" {
default: "default: pre-order walk,\none chunk per node"
}
tidaldy: "tidaldy override" {
pages: "playlist/album:\none chunk per 50-track page"
}
playback: "playback loop" {
ops: "pending ops:\ncursor + cancel flag"
}
core -> tidaldy: overridden by
tidaldy.pages -> playback.ops: "bounded chunks, in order"
playback.ops -> playback.ops: "apply + broadcast per chunk"
```
## Boundaries and risks
- **Proto**: one additive field (`Queue.resolving = 4`). No RPC shape
changes; the queue ops stay fire-and-forget.
- **Trait**: one new `ProviderClient` method with a default implementation —
existing providers (there is one) compile unchanged if they skip the
override; the override is where the provider-level win lives.
- **Ordering fix is a behavior change**: multi-album artists now queue in
listing order instead of reversed. Strictly a fix, noted here because
someone may have gotten used to the bug.
- **Concurrent additive ops interleave between ops.** Each op's internal
order is kept; the interleaving matches command arrival order at the loop.
Accepted — same semantics a human doing two appends "at once" expects.
- **Old TUI + new server**: queue fills progressively, no indicator — pure
improvement, no breakage. New TUI + old server: `resolving` is always
false, indicator never shows, behavior as today.
- **Not in scope**: pagination of `get_lib_node` for *browsing* (the library
pane still fetches collections to exhaustion before rendering), queue
persistence, a progress percentage (total counts are known per collection
but not aggregated across a nested walk).

View File

@ -1,356 +0,0 @@
# Queue order: de-duplication and sorting
## Context
The queue is an ordered list of tracks the server owns; clients mutate it
through RPCs and learn the result from the update stream
(`docs/src/queue.md`). Every existing mutation either *adds* tracks
(`Replace`/`Append`/`Queue`/`Insert`), *drops* them (`Remove`/`ClearQueue`),
or moves the cursor (`SetCurrent`). Nothing **reorders** what is already
there, and nothing notices that the same track is in the queue twice.
Both gaps show up in ordinary use:
- Queue an artist, then a playlist that contains three of the same tracks,
and the queue plays them twice. Today the only fix is spotting the rows
and pressing `d` on each.
- Queue five albums by appending them, and they play in the order they were
appended, interleaved by however the provider listed them. There is no way
to say "group this by artist" or "shortest first" short of clearing the
queue and re-queueing in a different order.
So: two new queue-order operations — **dedup** (drop duplicate entries) and
**sort** (reorder by a strategy) — for every client, without re-resolving
anything through providers.
## Assumptions
- **A1 — The server owns queue state; clients ask and then observe.** Same
rule as `architecture/seek.md` A2 and `architecture/mpris.md` A2: a client
sends the operation, the playback loop performs it, and the resulting
`Queue` snapshot is the truth. No client predicts the new order.
- **A2 — Order is queue state, shuffle is a modifier.** `tracks` is the
queue order (what every client renders, and what playback follows with
shuffle off); `play_order` is the *play* order shuffle permutes. Dedup and
sort rewrite the former. They are not new modifiers: nothing about them is
remembered, they are one-shot rewrites, and after one runs the queue is
just a queue in a different order.
- **A3 — Both work on metadata already in the queue.** `Track` carries
path, artist, title, album, duration and `provider_item_id`; no provider
round trip, no network, no new provider trait method.
- **A4 — The current track keeps playing.** Neither operation interrupts
audio: dedup never removes the playing track, sort never restarts it. The
playing track's *position* changes, which the existing `QueueTrack`
broadcast already covers.
- **A5 — A queue can be long.** Ten thousand entries after queueing a large
artist is normal (`architecture/progressive-queueing.md`), so both
operations must be O(n log n) with keys computed once per track, not per
comparison, and must not hold the queue lock across an await.
## Options considered
### Where the operations run
**Option A — client-side, on top of the existing RPCs.** A client already
holds the full `Queue` snapshot, so it could find duplicate positions itself
and send `Remove`. Sorting would mean sending `Replace` with the paths in the
new order — which re-resolves every path through its provider (slow, and a
provider that has since changed its listing returns something else), loses
the playing track (a replace restarts playback at the head), and would make
each client reimplement the comparators. And the `Remove` positions are
computed from a snapshot that a still-running resolve can invalidate before
the request lands.
**Option B — server-side RPCs *(chosen)*.** Both run on the playback loop,
the single writer of queue state: atomic against resolve chunks and against
other clients, no re-resolution, the current track is identified by *index*
and not by re-lookup, and the result reaches every client (and the persister)
through the one broadcast site that already exists.
**Option C — a general `ReorderQueue(permutation)` RPC.** The client decides
the order and the server applies it; sort strategies would then be a purely
client-side concern, and the same RPC would later serve drag-and-drop and
"move this track up". More general, but a permutation is an argument about
*positions*, and positions shift under a running resolve — a stale
permutation is not merely a no-op, it scrambles the queue. It also puts five
comparators in every client. Kept as **Deferred**: a move/reorder RPC wants
a different argument shape (identify rows by path, not index) and is its own
feature.
**Decision: Option B.** Two RPCs, `DedupQueue` and `SortQueue`, handled on
the playback loop like every other queue verb.
### What counts as a duplicate
1. **Same `path`.** Exactly the same library entry queued twice.
2. **Same provider item**`provider_item_id` scoped to its provider, with
`path` as the fallback when the id is empty. Catches the same track
reached by two routes: through an album, through a playlist, through a
search result.
3. **Same artist and title**, normalized. Would also catch the same song
from two different providers.
**Decision: 2 by default, falling back to 1** (D3) — and **3 behind an
explicit flag** (D3a). 3 cannot be the default: identical artist/title is
routinely a *different recording* — a live take, a remaster, a radio edit, the
studio version — and the server cannot tell which, so a default that merged
them would silently and unrecoverably edit the queue, with the false positives
landing exactly on the collections (greatest-hits, live albums, remix albums)
where a user is most deliberate. But it is what a listener sometimes means, so
it is reachable in one keypress and never by accident.
## Decisions
- **D1 — Two RPCs, both `QueueOwner`.** `DedupQueue` and `SortQueue` mutate
the queue, so they sit with `Remove`/`ClearQueue`/`SetCurrent` in the
rights matrix (`architecture/roles-auth.md`), not with the one appender
verb. The pinned method-list test in `auth.rs` keeps a new RPC from
reaching the wire unmapped.
- **D2 — `DedupQueue` answers with a count.** Every other queue verb
answers with an empty message because the update stream carries the truth.
Dedup is the exception: "how many did that remove?" cannot be recovered
from the new snapshot (a client would have to diff against a snapshot it
may never have had), and `0` is the answer a user most needs — it says
*there were no duplicates*, as opposed to *nothing happened*. So
`DedupQueueResponse.removed`, produced on the loop and returned through a
bounded result channel, the way `SaveQueue` already reports.
`SortQueueResponse` stays empty: the new order *is* the answer, and it
arrives on the stream.
- **D3 — The default duplicate identity is `(provider, provider_item_id)`, or
the whole path.** The provider is the first path segment; ids are provider-internal
(the content store keys them the same way, `by_provider_id(provider, id)`),
so leaving them unscoped would let two providers' numeric ids collide and
merge two unrelated tracks. When the id is empty — most providers, and
every local file — the key is the full path, which is exact. The
consequence worth stating: a captured copy under `/crabidy` and its
streaming original are **not** duplicates, because they are different
providers. Conservative on purpose: a missed duplicate is a keypress, a
wrong merge is lost queue state.
- **D3a — …and an opt-in `by_title` identity beside it.** Measured against a
real queue, D3 alone removed nothing from 121 entries — every entry was a
distinct provider item — while the *user* saw duplicates: four "Referees
Don't Fall In Love", three "Sink Into The Hips". They were remixes, edits and
album versions of the same songs, which D3 is right to keep and which a
listener may still not want three of. So `DedupQueue` takes a flag: with
`by_title` the identity is the lowercased `(artist, title)` pair, and the
survivor of a group is the **longest** take (the full version rather than a
radio edit), still yielding to the playing entry. It stays opt-in and on its
own key, because it *discards recordings that differ* — the exact loss D3
refuses to make the default. Duration-tolerant matching was the third option
and is not worth its complexity: on the queue that motivated this, every
same-title group differed by tens of seconds, so any tolerance narrow enough
to be safe caught nothing.
- **D4 — The survivor is the current track, else the earliest.** Within a
group of duplicates, the entry at the current position survives if it is
in the group; otherwise the earliest one does, and every later copy goes.
"Keep the first" alone would remove the playing track whenever the playing
copy was a later one — a dedup that stops the music is a bug, not a
policy (A4).
- **D5 — Dedup is expressed as a removal.** It computes the positions to
drop and hands them to `QueueManager::remove_tracks`, which already
maintains `play_order`, shifts `current_offset`, and ignores out-of-range
positions. Since the current track is never in that list, `remove_tracks`
reports no successor to start and playback is untouched — the same code
path a client's `d` takes, so there is one removal implementation, not two.
- **D6 — Five sort strategies, on a proto enum.** `ARTIST`, `ALBUM`,
`TITLE`, `DURATION` and `REVERSE`, plus a `descending` flag.
`UNSPECIFIED` (the proto3 default, i.e. a client that forgot the field) is
`InvalidArgument`, never a silent default. `REVERSE` reverses the order
the queue is in and ignores `descending` — it is not a key, and reversing
descendingly is the same thing.
- **D7 — The sort is stable, and the keys are compound.** `ARTIST` sorts by
(artist, album) and `ALBUM` by (album) alone; within an equal key, queue
order survives — which for a queued album *is* its track order, and is
much closer to what a user means than sorting an album's tracks
alphabetically by title. A stable sort is what makes "sort by album, then
by artist" compose across two presses, too.
- **D8 — Unknown sorts last, in both directions.** An empty artist/album/
title and an absent duration go to the end ascending *and* descending. A
length-less web radio stream is not "the longest track", and a missing
album is not alphabetically first. One rule for every key, so a user never
has to remember which end the blanks pile up at.
- **D9 — Text comparison is case-insensitive and locale-naive.** Keys are
lowercased once per track (decoratesortundecorate, A5), then compared as
Unicode strings. No collation, no article stripping: "The Beatles" sorts
under T. Locale-aware collation would need a collation library and a
locale the server does not have (it has no user, only clients); doing it
half-way — special-casing English articles — would be wrong for every
other language in a music library.
- **D10 — Sorting reorders `tracks`; what plays next depends on shuffle.**
With shuffle **off**, `play_order` is rebuilt as the identity over the new
order and `current_offset` follows the current track: the queue order *is*
the play order, so a sort changes what plays next — the point of sorting.
With shuffle **on**, `play_order` is remapped through the sort permutation,
so the shuffled play sequence and the position within it are preserved
exactly: the user asked for a random order, and sorting the *display* must
not silently reshuffle. Either way the current track stays current and
keeps playing (A4).
- **D11 — Both are legal while a resolve is in flight, and neither waits for
it.** They apply to what the queue holds at that moment; chunks still
being resolved land afterwards at their insert index or at the end,
unsorted. Refusing (`FailedPrecondition`) would make both operations
flaky exactly on the big queues that need them, and waiting would block
the loop. Clients already render the `resolving` indicator, so "more is
still arriving" is visible; a second press settles the rest.
- **D12 — Both persist through the existing broadcast site.**
`broadcast_queue` hands the snapshot to the persister and to the stream,
so a dedup or a sort survives a server restart with no new persistence
code (`architecture/queue-persistence.md`).
- **D13 — Pure key logic lives in its own module.** `queue_order.rs` holds
the duplicate key, the sort keys, and the permutation — no state, no locks,
unit-testable directly. `QueueManager::dedup`/`sort` keep the
`play_order`/`current_offset` bookkeeping, because that invariant is
theirs. The split is what keeps D3/D7/D8 testable as data instead of
through a queue.
- **D14 — TUI: `u` dedups, `U` dedups by title, `S` opens a sort menu.** `u`
is "unique" and is free in the queue scope; the shifted form is the shifted
*behaviour* (D3a), which is the pattern `w`/`W` and `c`/`C` already use here
for "the same verb, more of it". Sorting needs a choice, so `S` opens a modal
overlay listing the five strategies with their letters (`a` artist, `l`
album, `t` title, `d` duration, `r` reverse; the capital of each sorts
descending), `Esc` closes. A menu rather than a chord sequence: the
strategies are discoverable in the overlay instead of only in the help
modal, and the app already has three modal overlays (help, input, search)
to follow. It is modal in the same strict sense — while it is open, the
bindings table is unreachable.
- **D15 — The dedup count is reported in the queue pane's title, briefly.**
`Queue — removed 7 duplicates`, for a few seconds, then back to normal.
The title already multiplexes VISUAL, the `/` query and the register count,
so no layout changes and no new region; and the message is queue-scoped,
which is where the user is looking after pressing `u`. The count travels
as a typed `MessageToUi` variant, not a preformatted string — the wording
is the UI layer's business.
- **D16 — The web client keeps the same keys and adds the two buttons a mouse
needs.** `u` dedups and `S` opens the sort menu as a dialog, because the
keymap is deliberately the TUI's (`architecture/web-client.md`); the menu's
rows are clickable as well as typeable. The queue toolbar gains a **sort**
button that opens that same dialog and a **dedup** button — without them
both operations would be invisible to a mouse, and routing the button
through the same menu keeps the strategy list in exactly one place (a
second widget listing five strategies is a second thing to keep in step).
The dedup count goes to the existing toast rather than a pane title.
- **D17 — CLI: `cbd queue dedup` and `cbd queue sort <key> [--desc]`.** The
strategy is a `clap` `ValueEnum`, so the shell completes it and a typo is
a parse error rather than an `InvalidArgument` round trip. `dedup` prints
the count it got back (D2).
## Structure
```d2
direction: right
clients: Clients {
tui: cbd-tui\nu / S menu
web: cbd-web\nu / S / select
cli: cbd queue\ndedup / sort
}
rpc: gRPC (QueueOwner) {
dedup: DedupQueue\n-> removed: u32
sort: SortQueue\n(strategy, descending)
}
loop: playback loop\n(single writer) {
cmd: PlaybackCommand\nDedupQueue / SortQueue
qm: QueueManager\ntracks + play_order
keys: queue_order\nduplicate key, sort keys
}
out: One broadcast site {
stream: Queue update\n(every client)
persist: persister\n(latest wins)
}
clients.tui -> rpc.dedup
clients.web -> rpc.dedup
clients.cli -> rpc.dedup
clients.cli -> rpc.sort
clients.tui -> rpc.sort
clients.web -> rpc.sort
rpc.dedup -> loop.cmd
rpc.sort -> loop.cmd
loop.cmd -> loop.qm: mutate
loop.qm -> loop.keys: keys / permutation
loop.qm -> out.stream
loop.qm -> out.persist
rpc.dedup -> clients.cli: removed count
```
The sort's effect on the two orders (D10) — the same permutation, applied
differently depending on shuffle:
```d2
shape: sequence_diagram
client: client
loop: playback loop
off: QueueManager\nshuffle off
on: QueueManager\nshuffle on
stream: update stream
client -> loop: SortQueue(ARTIST, asc)
loop -> off: sort(ARTIST, asc)
off -> off: tracks := sorted
off -> off: play_order := identity
off -> off: offset := the current track's new index
loop -> on: sort(ARTIST, asc)
on -> on: tracks := sorted
on -> on: play_order := remapped through the permutation
on -> on: offset unchanged
off -> stream: Queue (new order, new position)
on -> stream: Queue (new order, new position)
loop -> client: SortQueueResponse {}
```
## Boundaries
- **`crabidy-server::queue_order`** — pure functions: the duplicate key of a
track, the sort key of a track, and the permutation for a strategy. Knows
nothing about locks, play order or clients.
- **`crabidy-server::QueueManager`** — gains `dedup()` and `sort()`. Owns
the `play_order`/`current_offset` invariants; `dedup` delegates the actual
removal to `remove_tracks` (D5).
- **`crabidy-server::playback`** — two new `PlaybackCommand` arms, each a
lockmutatebroadcast on the loop. `DedupQueue` carries a result channel
for the count.
- **`crabidy-server::rpc` / `auth`** — the two methods, their argument
validation (`UNSPECIFIED` → `InvalidArgument`), and their row in the rights
matrix.
- **Clients** (`cbd-tui`, `cbd-web`, `cbd-cli`) — bindings/commands, the
sort-menu modal, and rendering the count. No client computes an order.
- **Unchanged**: providers, the content store, the resolve pipeline, and
every existing queue RPC.
## Risks
- **A pending insert's index goes stale.** Dedup shifts positions and sort
moves everything, so the remaining chunks of an in-flight `InsertAt` op
land somewhere else than the user pointed at. This is pre-existing — a
plain `Remove` while resolving does the same — and bounded by D11's
"settle it with a second press", but it is real.
- **Dedup is per-provider (D3), so the obvious cross-provider duplicate — a
captured track and its streaming source — stays.** It is the conservative
end of a trade-off, and it will read as a bug to someone.
- **A sort with shuffle on changes nothing audible** (D10) and may look
broken. The clients show shuffle state, and the queue visibly reorders.
- **Very large queues copy their tracks once per sort.** A `Vec<Track>`
permutation on ten thousand tracks is a handful of milliseconds on the
loop; well under the loop's other work (a resolve chunk), but it is work
done while no other command is served.
## Deferred
- **A preview for `by_title`** (D3a): a confirmation view listing which
recording of each song would survive, so the aggressive identity can be
inspected before it removes anything rather than only undone by re-queueing.
- **`ReorderQueue`/move**: drag-and-drop in the web client and `K`/`J` row
moves in the TUI, on an RPC that identifies rows by path rather than
index (Option C).
- **Sort by release year.** `Album.release_date` is a provider string and
not always ISO 8601, so a year needs the same parse-or-drop treatment the
TUI notification does; worth doing once that parse lives somewhere shared.
- **A remembered sort** ("keep the queue sorted by artist as tracks
arrive") — a modifier, which is a different feature from a one-shot
rewrite (A2), and one that fights progressive queueing.
- **Sort within a marked range only**, the visual-mode analogue of a partial
sort.

View File

@ -1,227 +0,0 @@
# Queue persistence
## Context and problem statement
The queue lives only in the playback loop's memory: restarting
`crabidy-server` loses it. The user wants
1. an **automatically maintained current queue**, persisted on every queue
operation and reloaded when the server starts, and
2. **named saved queues**: pressing `w` on the queue pane asks for a name
and stores the current entries under it.
The explicit framing: realize this **completely with the fs provider** — a
second `fsdy` instance pointed at a `queues/` folder inside the crabidy
config directory, one subfolder per queue, each holding serialized track
files.
## Assumptions (confirmed against the code)
- The proto already declares `SaveQueue(SaveQueueRequest{name})`; the
server handler is a no-op stub (`rpc.rs`). No wire change is needed.
- The playback loop is the single writer of queue state
(`Playback.queue: Mutex<QueueManager>`); every content change funnels
through `broadcast_queue`, every current-track change through `play`
(plus the shuffle/repeat toggles). Hooking those sites observes every
queue operation.
- `fsdy` track files carry metadata plus one playable; a `link` playable
rewrites `Track.path` to its target at listing time
(architecture/fs-provider.md D2). Queueing a folder of link files
therefore reconstructs the original tracks with zero new mechanisms.
- `w` is unbound in the TUI's `Queue` scope; the input overlay
(`InputState`/`InputPurpose`) already handles ask-for-a-name flows.
## Decisions
### D1 — Mount a second `fsdy` instance at `/queues`
Options considered:
- *(a)* A new provider crate (`queuedy`) that owns the queues folder.
- *(b)* Parameterize `fsdy::Client` with its provider root and mount a
second instance at `/queues` over `<config>/crabidy/queues`.
**Decision: (b)** — the user's framing, and the listing/parsing/routing
logic is byte-for-byte the same. `fsdy::Client` gains a constructor
`Client::new(provider_root, disk_root)`; the `ProviderClient::init` path
keeps building the `/fs` instance from `fsdy.toml`. The hardcoded
`"/fs/"` prefixes in `disk_path`/`is_track_path`/`list_dir` become
instance state. The orchestrator gains `queues_client` and `/queues`
routing arms; init creates the folder (`create_dir_all`) and is non-fatal
like `/fs` (a failure costs persistence, never the server). Loading a
saved queue is just browsing `/queues` and queueing a folder — no new
RPCs, no new TUI flows.
### D2 — Persist a queue as a folder of order-prefixed **link** files
Every queue entry becomes `NNNN <title>.cbd-track.toml` with the entry's
metadata (title, artist, duration, album) and `playable.link =
Track.path` — uniformly, for every entry. The 4-digit zero-padded prefix
makes the case-insensitive listing sort reproduce queue order; the
sanitized title keeps the files human-readable. Round trip: listing
rewrites each link track's path back to its target, so reloading yields
the original tracks with the persisted metadata.
**Consequence — the "no links into `/fs`" rule falls.** A queue may
contain `/fs/...` tracks (file/url playables keep their fs path), so
persisted files must be able to link into an fs-provider instance. The
original rejection (fs-provider D3) existed to prevent chains; it is
replaced by the stronger structural argument: **links are one hop by
construction** — `get_urls_for_track` never follows a link (a
link-playable target is `MalformedPath`), so a link whose target is
itself a link file dies at play time with a warning, and cycles cannot
recurse anywhere. `TrackFileError::LinkIntoFs` is removed; a link must
merely be an absolute path. `architecture/fs-provider.md` D2/D3 are
reconciled with this.
Not chosen: inlining the target's `file`/`url` playable into the saved
file — the persister only has the wire `Track` (path + metadata), and
links keep the saved queue pointing at the *node*, surviving edits to the
underlying track file.
### D3 — Layout: `<config>/crabidy/queues/<name>/`, current queue = `current`
- The automatically maintained queue lives in `queues/current/` — a
visible, ordinary queue folder (it shows up under `/queues` like any
saved queue). The name is **reserved**: `SaveQueue("current")` is
rejected so a named save is never silently clobbered by auto-persist.
- Each queue folder carries a hidden sidecar `.queue-state.toml`
(`current_position`, `repeat`, `shuffle`). Dot-prefixed → invisible to
the provider listing. It is written for every queue and read only when
restoring `current` at startup.
- Writes go to a hidden sibling temp dir (`.tmp-<name>`), then the old
folder is removed and the temp renamed into place. Not atomic (rename
over a non-empty dir is impossible); the crash window can lose the
folder — accepted for a local music queue, and a warning covers it.
- Saving an existing name overwrites it (same temp-and-swap).
### D4 — Auto-persist through a latest-wins channel and one persister task
The playback loop must never block on disk. Every queue-state change
sends a snapshot (`tracks`, `current_position`, `repeat`, `shuffle`)
into a `tokio::sync::watch` channel (bounded, single slot, latest wins —
a burst of resolve chunks coalesces naturally). A dedicated persister
task awaits changes, debounces briefly, skips writes whose snapshot
equals the last one written (broadcasts that only toggled the
`resolving` flag stay free), and rewrites `queues/current/` per D3. Disk
failures are warnings; playback is never affected. Send sites: the
`broadcast_queue` funnel, the current-track broadcast in `play`, and the
shuffle/repeat toggle handlers.
### D5 — Restore at startup, bespoke, never autoplay
`Replace(["/queues/current"])` through the normal resolve flow was
rejected: it starts playback (a restarted server must stay silent), and
it cannot restore the queue position. Instead, before the loops start
serving, the server reads `queues/current/` directly — sorted listing,
`TrackFile::parse`, `to_track` (identical semantics to the provider) —
applies the tracks to the `QueueManager`, restores
`current_position`/`repeat`/`shuffle` from the sidecar, and leaves
`PlayState::Stopped`. A missing folder is a fresh start; a broken file is
skipped with a warning like any listing.
### D6 — `SaveQueue` wiring
`rpc save_queue` sends `PlaybackCommand::SaveQueue { name, result_tx }`
to the playback loop (single-writer discipline: only the loop may
snapshot). The loop validates and snapshots, then hands the write to a
spawned task so it never blocks on disk; the RPC reply reports the actual
write result. Errors: invalid name (empty after trim, contains a path
separator or NUL, starts with `.`, or is `current`) →
`invalid_argument`; empty queue → `failed_precondition`; I/O →
`internal`.
### D7 — TUI: `w` on the queue pane
New `Action::QueueSaveAs` bound to `w` in `Scope::Queue` ("Save queue
as…"). It opens the existing input overlay with a new
`InputPurpose::SaveQueue` (label `save queue`), no-op while the queue
is empty. Submit sends `MessageFromUi::SaveQueue(name)` → the
`SaveQueue` RPC. The saved queue appears under `/queues` on the next
library visit — no push update needed.
### D8 — Out of scope (explicitly)
- ~~Renaming/deleting saved queues from the TUI~~ — delivered by the
bookmarks feature (architecture/bookmarks.md D4): `/queues` mounts with
an editable top level (reserved: `current`), so saved queues are
renamable (`e`) and deletable (`d`). Creating nodes stays
`NotSupported`.
- Making the queues directory configurable; it is derived from the
config dir.
- Persisting the playback *position within the track*, autoplay on
restore, or multiple current queues.
## Structure
```d2
direction: right
server: crabidy-server {
pb: Playback loop {
q: "QueueManager (single writer)"
}
persister: "persister task" {
w: "debounce, skip unchanged,\nwrite current/"
}
store: QueueStore {
s: "validate name, tmp-and-swap"
}
orch: ProviderOrchestrator
}
fs: "fsdy /fs\n(music root)"
qfs: "fsdy /queues\n(config queues dir)"
disk: "config/crabidy/queues" {
shape: cylinder
cur: "current/ + .queue-state.toml"
saved: "<name>/ per saved queue"
}
server.pb -> server.persister: "watch channel\n(latest snapshot wins)"
server.persister -> server.store: persist current
server.pb -> server.store: "SaveQueue(name)\n(spawned write)"
server.store -> disk
server.orch -> qfs: "/queues/..."
server.orch -> fs: "/fs/..."
qfs -> disk: "list + parse (read only)"
```
## Key flow: save, restart, reload
```d2
shape: sequence_diagram
tui: TUI
rpc: gRPC
pb: Playback loop
store: QueueStore
orch: Orchestrator
tui -> rpc: "SaveQueue(road trip)"
rpc -> pb: "PlaybackCommand::SaveQueue"
pb -> store: "snapshot -> spawned write"
store -> rpc: "queues/road trip/ written"
rpc -> tui: OK
tui -> pb: "(server restarts; restore reads current/)"
tui -> orch: "GetLibraryNode(/queues)"
orch -> tui: "children: [current, road trip]"
tui -> pb: "ReplaceQueue([/queues/road%20trip])"
pb -> orch: "resolve: links rewritten to targets"
```
## Risks and open questions
- **Hand-written files in `queues/`** behave like any fs tree (broken
files skipped with warnings). A hand-written `url`/`file` track keeps
its `/queues/...` path when queued; persisting then links to that
file — one hop, resolves fine.
- **Queues past 9999 tracks** sort wrong beyond the 4-digit prefix;
accepted (prefix width is a constant).
- **Concurrent saves to the same name** race on the temp dir; last
writer wins. Accepted for a single-user local server.
- **Metadata drift**: a saved queue replays the metadata captured at
save time, not the target's live metadata — consistent with
fs-provider D2.
- Open (future): deletable saved queues in the TUI; a `SaveQueue`
confirmation/overwrite prompt; persisting the in-track position.

View File

@ -1,265 +0,0 @@
# Queue selection, visual mode, and the register
Marks and visual mode in the queue pane, paired with a vim-style
**register**: deleting or yanking queue entries puts them in the register,
pasting brings them back. One model, implemented in both clients.
## Context and problem statement
The library pane has marks (`s`) and visual mode (`v`/`V`); the queue pane
has neither — `d` removes exactly the row under the cursor, one at a time,
and `queue.rs` carries the standing note *"FIXME: mark multiple tracks on
queue and remove them"*. Clearing 200 tracks with `c`/`C`, or deleting the
wrong row, is unrecoverable: there is no undo anywhere in the system.
Meanwhile `p` in the queue means "insert the **library** selection after
this track" — a cross-pane action with no name for what it is inserting.
The ask is to close both gaps with one concept borrowed from vim: a client
register that deletes and yanks write to, and paste reads from. That turns
multi-delete into a safe operation (it is recoverable), gives "move these
three tracks down" a natural spelling (`d` then `P`), and gives `p` a single
meaning.
## Assumptions (confirmed)
- **Only explicit commands write the register**: `y` (both panes), and `d`,
`c`, `C` (queue). Marking or moving the cursor never writes it. This was
the one point of disagreement — an implicit fill from the library
selection would have kept `p` backward-compatible, but it means `s` in
the library silently clobbers a clipboard you are about to paste.
- **Both `p` (after cursor) and `P` (before cursor)**, so `d` then `P`
restores exactly what you deleted, and `d``p` is a move.
- **`c`/`C` fill the register too** — the destructive ops most worth
undoing.
- **One unnamed register**, but the type is shaped so named registers
(`"a`) are additive rather than a rewrite.
- **Both clients, now.** `cbd-web` gets queue marks, queue visual mode, and
the register — *plus* the library visual mode that was deferred when
`v`/`V` landed in the TUI, so the two clients do not drift further.
- The register is **per-client, in-memory, one level deep**. It is not a
server-side undo log; another client cannot undo your delete, and a
restart forgets it. Same expectations as vim.
## What the protocol already gives us
No `.proto` change, no server change. The two RPCs this feature needs are
already shaped for it:
- `Remove { positions: repeated uint32 }` — already takes **many**
positions, so multi-delete is a client-side gather.
- `Insert { position, paths: repeated string }` — takes a **path list**, so
a register of paths pastes with the existing call.
That is what makes this a client-only feature.
## Options considered
### What the register holds
1. **Resolved tracks** (`Track` messages, as deleted). Exact: what you
deleted is what comes back, and the UI can show real titles. But it
cannot express "the album node I yanked in the library", and pasting
still has to send paths, so the extra fidelity buys nothing at the wire.
2. **Paths, with titles alongside for display** (chosen). It is what both
RPCs speak; a yanked library *node* expands at paste time, which is a
feature (`y` an album, `p` it into the queue); and the register is one
`Vec<String>` plus labels.
The cost is honest and worth stating: paste **re-resolves**. A yanked
child of a search term whose term has since been deleted may not come
back, and a node's track count is unknown until paste, so the UI can
only say "3 entries", not "12 tracks".
### Keeping marks alive in a moving queue
This is the only genuinely new problem. The library never had it: a listing
is a snapshot you re-fetch deliberately. The queue is server-pushed and
rebuilt on **every** change — another client appending, playback advancing,
and each chunk of a streaming resolve.
1. **Remap marks by index.** Cheapest, and silently wrong: if playback
advanced and the queue shifted by one, `d` deletes the wrong tracks.
Rejected — a data-losing failure mode.
2. **Clear marks on every snapshot.** Never wrong, but a resolve streaming
in or another client's append wipes a selection mid-flow, which makes
the feature feel broken exactly when the queue is busy.
3. **Greedy in-order match on track path** (chosen). Walk the old and new
path lists together and carry a mark to the new position of the same
path; drop marks whose track is gone. Survives appends, removals, and
playback advancing. Duplicate paths (the same track queued twice) are
ambiguous by nature — in-order matching degrades sanely, keeping the
*n*-th occurrence marked. If the lists diverge past recognition (no
common prefix worth speaking of), clear rather than guess.
The safety rule that makes any of this sound: **positions handed to
`Remove` are always read off the newest snapshot**, never a remembered
index.
### Where marks live
The two clients are shaped differently and this is not worth papering over:
- `cbd-tui` owns a `Vec<UiItem>` per pane and already has `marked` on it
(the queue builds every row `marked: false` today).
- `cbd-web` owns **no** queue list at all — just `QueueCursor { selected }`,
with rows rendered straight from the server's `Queue` signal.
So the shared thing is the *rule*, not the struct: marks are a set of
positions plus the path list they were taken against, reconciled on each
snapshot. The TUI keeps them in its `UiItem`s (it rebuilds that list
anyway); the web client keeps a position set beside the signal. Both
implement the same reconciliation, and both unit-test it against the same
cases.
## Decisions
**D1 — `Register` holds paths plus display labels.** One unnamed slot:
```rust
struct Register {
/// Library paths, in the order they were yanked or deleted. Empty means
/// nothing to paste.
paths: Vec<String>,
/// Row labels for the status line only — never sent anywhere.
labels: Vec<String>,
}
```
Named registers stay additive: the owner becomes a small map keyed by a
register name, with `None` meaning the unnamed one. Not built now.
**D2 — Only `y`, `d`, `c`, `C` write the register.** Marks, visual mode,
and cursor movement never do. A write **overwrites**: there is no history
and no numbered registers.
**D3 — `p` pastes after the cursor, `P` before it.** Both are queue-only
(there is nothing to paste into a library listing). Paste sends
`Insert { position, paths }` and leaves the register intact, so you can
paste twice. `d` then `P` is an exact restore; `d``p` is a move.
**D4 — `y` works in both panes.** In the library it yanks the marked items,
or the cursor item, under the same `is_queable` gate that `a`/`Enter` use —
so yanking cannot put an unqueueable folder in the register. In the queue it
yanks the marked rows, or the cursor row. `y` clears the marks it consumed,
exactly as queueing does today.
**D5 — Queue marks reconcile per snapshot** by the greedy in-order path
match above, clearing on divergence. `Remove`/`Insert` positions always come
from the newest snapshot.
**D6 — `d` in the queue deletes every marked row** (or the cursor row when
nothing is marked), in one `Remove` call, and writes them to the register
first. `c`/`C` write the tracks they are about to drop.
**D7 — Queue visual mode mirrors the library's** anchored paint: `v` (and
`V`) enters, movement toggles the marks of the swept range against the
anchor so moving back reverses, `Esc` or any non-movement action leaves it.
The mark+visual logic is extracted so both panes share one implementation
per client, rather than a second copy in the queue.
One concrete wrinkle: queue rows are built `is_queable: false,
is_deletable: false`, so the library's mark gate would refuse all of them.
The extracted code takes the gate as a parameter — the queue's is "always
allowed".
**D8 — `cbd-web` reaches parity in the same change**, including the library
visual mode it does not have yet. Its queue marks live beside the server
signal (D-above); its keymap and help overlay gain the same rows as the
TUI's.
**D9 — Keys.** The queue gains `s`, `v`, `V`, `y`, `P`; the library gains
`y`. No chord collides with an existing one in either scope.
**D10 — `p` changes meaning, and that is a breaking change** to muscle
memory: it pastes the register instead of the library selection. The
browse→queue flows that do not go through `p` (`a` append, `L` queue-next,
`Enter` replace) are untouched, so the cost is confined to "insert what I
picked on the left at this exact position", which becomes `y` then `p`.
Documented in the key tables and in the book.
## Structure
```d2
direction: right
lib: Library pane {
libsel: "marked rows,\nelse cursor row\n(is_queable gate)"
}
q: Queue pane {
qsel: "marked rows,\nelse cursor row"
}
reg: "Register (per client, one slot)\npaths + labels" {
shape: cylinder
}
rpc: Server (unchanged) {
ins: "Insert { position, paths }"
rem: "Remove { positions }"
}
lib.libsel -> reg: "y"
q.qsel -> reg: "y · d · c · C"
q.qsel -> rpc.rem: "d · c · C\n(positions from the\nnewest snapshot)"
reg -> rpc.ins: "p (after cursor)\nP (before cursor)"
rpc -> q: "Queue snapshot\n(marks reconciled)"
```
Mark reconciliation, on every queue snapshot:
```d2
direction: down
snap: "Queue snapshot arrives"
cmp: "Walk old paths and new paths\nin order"
carry: "Carry each mark to the new\nposition of the same path"
drop: "Drop marks whose track is gone"
clear: "Clear all marks"
act: "d / y / c / C read positions\nfrom this snapshot only"
snap -> cmp
cmp -> carry: recognizable
cmp -> clear: "diverged past\nrecognition"
carry -> drop
drop -> act
clear -> act
```
## Boundaries and interfaces
- **Register** — owned by each client's app state, written only by the four
commands, read only by paste. No I/O, no server involvement; a pure value
that is trivially unit-testable.
- **Mark reconciliation** — one function per client, `(old_paths,
new_paths, marks) -> marks`, tested against: append, remove-before,
remove-marked, playback advance, streaming resolve, duplicate paths, and
wholesale replacement.
- **Pane selection** — both panes expose "the rows this action applies to"
the way the library's `get_selected` already does; `y`/`d` consume it.
- **Server** — untouched. This whole feature is two clients.
## Risks
- **Mark drift** deleting the wrong tracks. The reconciliation rule and the
newest-snapshot rule exist for this; it needs the strongest tests in the
feature.
- **`p`'s changed meaning** surprising existing users. Mitigated by leaving
`a`/`L`/`Enter` alone and documenting the change; not avoidable if `p` is
to have one meaning.
- **Silent paste shortfall** when a path no longer resolves — you paste 5
and get 4. The server already skips unresolvable paths in a resolve; the
clients should say what they pasted rather than claim success blindly.
- **Two implementations drifting** (the very thing D8 is fixing for visual
mode). Same rule, same test cases, both landed together.
- **Duplicate-path ambiguity** in reconciliation is inherent, not solvable
without a per-entry queue id in the proto. In-order matching is the
honest approximation; a queue id is the escape hatch if it ever bites.
## Open questions
None blocking. Deferred by choice: named registers (D1 leaves room), a
numbered/history register stack, `y` in the queue putting entries somewhere
persistent (that is what `w` and saved queues are for), and a per-entry
queue id in the proto to make reconciliation exact.

View File

@ -1,187 +0,0 @@
# Roles and rights (basic-auth authorization)
crabidy listens on `0.0.0.0:50051`: anyone on the network can control
playback and — worse — rename or delete library stores. The owner wants
to hand out *limited* remotes: a co-host who may run the queue but not
touch the library, and guests who may only add tracks.
## Problem statement
Three roles, credentialed by password hashes in the server config:
- **owner** — the normal user; everything.
- **queue-owner** — anything on the queue (and playback), but no
library writes: no `w`, no `W`.
- **queue-appender** — may only add tracks to the end of the queue;
no removal, no reordering, no queue settings.
## Assumptions (confirmed by the request, or decided here)
- Transport stays plain HTTP/2 gRPC. Basic auth over cleartext is
acceptable on a trusted home network; anything else (Internet
exposure) needs TLS termination in front (reverse proxy, VPN) and is
out of scope. The README says so.
- **Anonymous callers inherit the highest *unguarded* role.** A request
with no credentials is not rejected outright; it is granted the most
privileged role whose password is *not* set. So the config guards
from the top down and each password lowers what anonymous users can
do:
- nothing guarded → anonymous is **owner** (today's open server);
- `owner` guarded → anonymous is **queue-owner**;
- `owner` + `queue_owner` guarded → anonymous is **queue-appender**;
- all three guarded → anonymous can do **nothing** (`UNAUTHENTICATED`).
A credential still elevates a caller to its role; the anonymous role
is only the floor. A *present-but-wrong* credential is denied, never
silently downgraded to the anonymous role.
- **Guarding order is enforced.** Because anonymous callers get the
highest unguarded role, guarding a lower role while a higher one is
open is meaningless — the anonymous role would still outrank it. The
valid guarded sets are therefore prefixes of `[owner, queue_owner,
queue_appender]`. A config that sets `queue_owner` without `owner`
(or `queue_appender` without `queue_owner`) is **broken and aborts
startup**, fail-closed; `crabidy-server guard` likewise refuses to
write such a config.
- One password per role, not per person. The Basic-auth *username*
selects the role (`owner`, `queue-owner`, `queue-appender`), the
password is verified against that role's hash.
## Options considered
### Where to enforce
1. **Per-handler checks** inside `RpcService` (tonic interceptor
authenticates, each of the ~25 handlers calls
`require(role)?`). Idiomatic tonic, but *fail-open*: a future RPC
that forgets the line is unprotected.
2. **One tower layer** in front of the tonic service, mapping the
gRPC method path to a minimum role, *default-deny* (unknown method
⇒ owner only). Fail-closed, single enforcement point, zero handler
churn; costs a small amount of manual HTTP plumbing for the
deny response (gRPC trailers-only response).
**Decision: option 2.** Authorization is a security boundary; new
RPCs must start locked. A unit test pins the method table against the
proto service definition so an unmapped addition fails loudly.
### Hash scheme
PHC-format strings verified with the pure-Rust `argon2` crate
(RustCrypto). Argon2id is the default the crate generates; any PHC
variant the crate parses is accepted. bcrypt/scrypt support is not
worth a second dependency. To keep users out of hash-tooling misery,
`crabidy-server hash-password` reads a password on stdin and prints
the PHC string to paste into the config.
### Verification cost
Argon2 verification is deliberately slow (tens of ms); per-keypress
RPCs cannot re-verify. Successful credentials are cached in memory
(`authorization` header value → role). Only *successful* verifications
are cached, so the cache is bounded by the number of valid credentials
(≤ 3); failures pay the full argon2 cost every time, which doubles as
throttling.
## The rights matrix
Minimum role per RPC; higher roles include lower ones
(owner ⊃ queue-owner ⊃ queue-appender):
- **queue-appender** (and up): `Init`, `GetLibraryNode`,
`GetUpdateStream` (reads), `Append` (the one queue write), and
`CreateLibraryNode` — creatable nodes are exactly the search terms,
and guests must be able to search for what they append. (Search
terms do persist in the owner's provider config; accepted — they are
the mechanism of finding tracks, not library data.)
- **queue-owner** (and up): every other queue and playback verb —
`Queue`, `Replace`, `Remove`, `Insert`, `ClearQueue`, `SetCurrent`,
`ToggleShuffle`, `ToggleRepeat`, `TogglePlay`, `Stop`, `Next`,
`Prev`, `RestartTrack`, `ChangeVolume`, `ToggleMute`.
- **owner** only: the library writes — `CaptureLibraryNode` (`w`/`W`),
`SaveQueue` (writes `/queues`), `RenameLibraryNode`,
`DeleteLibraryNode` (these two also cover search terms; a
queue-owner can create terms but not rename/delete them — the
server cannot cheaply tell a term from a bookmark store at this
layer, so renames/deletes stay owner-only, fail-closed).
- **unknown / future methods**: owner only.
A caller whose role (anonymous or credentialed) is below the method's
minimum gets `PERMISSION_DENIED`; a present-but-wrong credential, and
an anonymous request to a fully-locked server, get `UNAUTHENTICATED`.
Credentials are never logged (hard rule: secrets redacted).
## Configuration
`~/.config/crabidy/crabidy-server.toml` (new, read by the server —
both standalone and inside `cbd`; absent file = auth off):
```toml
[auth]
# One PHC hash per role. Guard from the top down: setting a role's hash
# lowers what an anonymous (no-credential) caller may do to the next
# role below. Omitting a role leaves it open, so anonymous callers get
# the highest omitted role. Setting a lower role without the higher one
# (e.g. queue_owner without owner) is rejected at startup.
# Generate with: crabidy-server guard <role>
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..."
```
`~/.config/crabidy/cbd-tui.toml` (client side, also as CLI flags):
```toml
address = "http://127.0.0.1:50051"
# Sent as HTTP basic auth when set. `user` is the role name.
user = "queue-owner"
password = "plaintext"
```
The client config holds a *plaintext* password (client credentials
always are); the README tells users to keep the file private. The TUI
attaches `authorization: Basic …` to every request through a tonic
interceptor; without configured credentials it sends no header, which
keeps today's zero-config local setup working against an open server.
The web client stores its credentials in `localStorage` and attaches
them the same way. Because an anonymous browser silently connects as the
fallback role, it would otherwise never learn a login is possible, so
the `Init` response (reachable anonymously) carries an `auth_enabled`
flag: on first connect with no stored credentials against an
auth-enabled server, the client raises a login dialog. That dialog is
**dismissible** — "continue as guest" keeps the fallback role — so the
zero-typing browse path is preserved. When the server denies anonymous
access outright (all roles guarded → `UNAUTHENTICATED`), the same dialog
appears without the guest option, because credentials are then the only
way in.
## Structure
```d2
direction: right
tui: cbd-tui {
cfg: "cbd-tui.toml user/password"
interceptor: "auth interceptor\n(adds Basic header)"
cfg -> interceptor
}
server: crabidy-server {
layer: "AuthLayer (tower)\nheader → role → method check"
rpc: RpcService
cache: "verified-creds cache"
cfg: "crabidy-server.toml [auth] hashes"
cfg -> layer
layer -> cache: hit = skip argon2
layer -> rpc: authorized
}
tui.interceptor -> server.layer: every RPC
server.layer -> tui: "UNAUTHENTICATED /\nPERMISSION_DENIED"
```
## Risks and open questions
- **Denied-action UX**: the TUI currently logs RPC errors; an
appender pressing a forbidden key sees a silent no-op (the log has
the denial). A status-line hint is deferred until the flow has been
felt in practice.
- **Cleartext transport**: documented; TLS stays out of scope.
- The update stream is authorized once at subscription; the stream
itself only carries state every role may read.

View File

@ -1,263 +0,0 @@
# RSS provider — /rss
Subscribe to podcast feeds by URL and play their episodes. Mounted at
`/rss` by a new `rssdy` crate. Listings are **never cached**: every visit
to a subscription fetches the feed, so a new episode shows up the moment
it is published.
## Context and problem statement
`/fyyd` already plays podcasts, but it is a *discovery* provider: it
searches fyyd's public directory and gets episode metadata from fyyd's
API. It cannot play a feed fyyd does not index, and it cannot play a
**private** feed at all.
Plenty of podcasts are distributed as a plain RSS URL, and paid ones as a
per-subscriber URL with a token in it:
```text
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
```
That URL *is* the credential. Anyone holding it has the subscription.
So the ask is: a list of feeds I subscribe to, each browsable as a folder
of episodes, always showing the newest ones, and safe to use with
tokened URLs.
## Assumptions (decided)
- **Subscriptions are configuration, not discovery.** There is no feed
search; you name the feeds you want. `%` on `/rss` adds one from a
pasted URL and persists it (D5), which is how you "subscribe to
several" without hand-editing TOML.
- **A feed URL is a secret**, because a premium URL is a bearer
credential. It is redacted from `Debug` and never logged, and — the
decision that shapes the whole tree — **it never appears in a library
path** (D1).
- **Nothing about feed content is cached** (D2). This is the explicit
requirement, and it has a client-side half people forget: both clients
cache library listings by path, so the server being fresh is not
enough.
- Feeds are read-only. No OPML import, no per-episode read state, no
"unplayed" tracking — this is a player, not a podcatcher.
## Options considered
### The parser
1. **`feed-rs` 2.4** (chosen) — one crate for RSS 2.0, RSS 1.0, RSS
0.x, Atom and JSON Feed, with the iTunes extensions mapped into its
model. Podcast feeds in the wild are not uniformly RSS 2.0, and this
is the only option that does not make us care.
2. `rss` + `atom_syndication` — two crates, two models, and we write the
normalisation. Rejected: more code for less coverage.
3. `quick-xml` by hand — full control, and a long tail of real-world
feed quirks to discover the hard way. Rejected.
### Episode identity — the interesting problem
A track path has to survive being **stored**: it goes into the queue, is
persisted in `current`, and a bookmark (`w`) writes it into a toml that
is replayed days later. It is also *displayed*. So the path may not
contain the feed URL, and it should not contain an arbitrary
publisher-chosen id verbatim (guids are unbounded and can be URLs
themselves).
1. **Enclosure URL in the path** (base64 or percent-encoded). Resolves
with no feed fetch, and a bookmark keeps working forever. Rejected on
two counts: a premium enclosure URL leaks its token into the UI, the
logs, and every saved queue; and paths become unreadable.
2. **Index in the feed** (`/rss/economist/3`). Stable only until the next
episode is published, which is the one thing guaranteed to happen.
Rejected — a bookmark would silently point at a different episode.
3. **A short hash of the episode's guid** (chosen), falling back to the
enclosure URL when a feed omits the guid: `/rss/<slug>/<16 hex>`.
Short, readable, token-free, and stable for as long as the publisher
keeps the guid stable (which is what guids are for). Resolving one
means fetching the feed and matching the hash — which we are doing
anyway (D2), and the memo (D3) keeps it to one fetch per action.
`blake3` is already a workspace dependency, and using it here keeps
the hash stable across builds (`DefaultHasher` explicitly is not).
### Freshness versus hammering the feed
"Always fetch" taken literally means listing a 40-episode feed, queueing
it, and playing a track costs 40+ feed fetches. Options:
1. A TTL cache. Rejected: it turns the requirement into a timing
question ("is 30 seconds fresh?") and gets it wrong twice.
2. **A listing-driven memo** (chosen, D3). A *listing* always fetches and
replaces the memo entry for that feed. Track lookups
(`get_urls_for_track`, `get_metadata_for_track`,
`resolve_tracks_into`) read the memo and only fetch when they miss.
Freshness is exactly "what a visit shows", with no clock involved, and
an episode you can see is an episode you can play.
## Decisions
**D1 — Subscriptions are `(name, url)` pairs in `rss.toml`; the path
carries a slug of the name.**
```toml
# Per-subscription. `name` is yours; the path uses a slug of it.
[[feeds]]
name = "The Economist Podcasts"
url = "https://feeds.economist.com/v1/rss/…/f74365b0-…"
[[feeds]]
name = "Cautionary Tales"
url = "https://feeds.example.org/cautionary-tales"
```
`/rss/the-economist-podcasts` is the node. The URL stays in the config
file and never reaches a path, a log line, or a saved queue. Duplicate
slugs get a numeric suffix; a feed whose entry has no usable url is
skipped with a warning at load.
**D2 — No caching of feed content, at either end.**
- The provider holds no listing cache: `get_lib_node("/rss/<slug>")`
fetches.
- **`/rss` joins `MUTABLE_ROOTS` in both clients** (`cbd-tui/src/rpc.rs`,
`cbd-web/src/state.rs`), which is what actually makes a re-visit
re-fetch. Without this the client answers from its own cache and the
server's freshness is invisible.
**D3 — One memo, written by listings, read by track lookups.** A bounded
map (last 8 feeds) of `slug -> episodes`, replaced on every listing of
that feed. Track lookups consult it and fetch on a miss. It is never
consulted to answer a listing.
**D4 — Episode key is `blake3(guid)[..16]`**, or `blake3(enclosure_url)`
when the feed omits a guid. `Track.provider_item_id` is set to the guid,
so the content store de-duplicates captures of the same episode across
visits and across feeds.
**D5 — `%`, `e`, `d` manage subscriptions.** `%` on `/rss` takes a pasted
feed URL, fetches it once, names the subscription from the feed's own
title (slug-deduped), appends it to `rss.toml` and writes the file back —
the same shape as SoundCloud's `resolve` node taking a URL as its
"title", and the same write-back the other providers already do for their
settings. `e` renames a subscription (the slug, and so the path, changes
with it). `d` unsubscribes: it removes the config entry and touches no
audio. A capture made from it stays under `/crabidy`.
**D6 — Bounded and defensive.** Per-request timeout
(`call_timeout_secs`, default 30), a response-size cap (`max_feed_bytes`,
default 8 MiB) so a hostile or broken feed cannot exhaust memory, and
`episodes_per_feed` (default 200). A malformed entry — no enclosure, no
title, unparseable date — is skipped with a warning; only a feed that
cannot be fetched or parsed at all is an error, and it fails just that
node, never the server.
**D7 — Behind the `rss` cargo feature**, default on, exactly like the
other providers: `dep:rssdy`, a `BUILT_IN_PROVIDERS` entry, a
`ProviderToggles` field, a name in `ALL_PROVIDERS`, and a row in
`check-features`.
**D8 — A separate crate, not part of `fyyd`.** They share only "an
episode is a track whose audio is an enclosure URL". Their config,
identity model, and caching differ completely; folding them together
would mean one crate with two personalities. If a third podcast source
ever lands, the shared piece to extract is the episode→`Track` mapping,
not the provider.
**D9 — Newest first.** Episodes are sorted by publication date
descending where dates parse, keeping feed order for the rest (a stable
sort, so a dateless feed lists exactly as published). Queueing a
subscription therefore plays newest first.
**D10 — Episodes stream directly, like fyyd's.** The enclosure URL is a
plain media file; the existing player streams it with no sidecar and no
new player component. Captures (`W`) download it into the content store.
## Structure
```d2
direction: right
config: "rss.toml\n[[feeds]] name + url\n(url is a credential)" {
shape: document
}
rssdy: "rssdy (/rss)" {
subs: "subscriptions\nname -> url"
memo: "episode memo\nslug -> episodes\n(last 8, listing-written)"
parse: "feed-rs\nRSS/Atom/JSON Feed"
}
feeds: "publisher feeds\n(https, tokened URLs)" { shape: cloud }
player: "audio player\n(enclosure URL)"
clients: "cbd-tui · cbd-web\n/rss is never cached client-side"
config -> rssdy.subs: loaded at init
clients -> rssdy: "GetLibraryNode(/rss/<slug>)"
rssdy.subs -> feeds: "fetch on every listing"
feeds -> rssdy.parse
rssdy.parse -> rssdy.memo: replaces the entry
rssdy.memo -> player: "enclosure URL for a track key"
```
Listing versus playing — the same feed, two paths through it:
```d2
direction: down
visit: "visit /rss/<slug>"
fetch: "fetch + parse the feed"
memo_w: "replace memo[slug]"
list: "list episodes, newest first\npath = /rss/<slug>/<hash(guid)>"
play: "play or queue an episode"
memo_r: "memo[slug] hit?"
enclosure: "enclosure URL -> player"
visit -> fetch
fetch -> memo_w
memo_w -> list
play -> memo_r
memo_r -> enclosure: hit
memo_r -> fetch: "miss (restart, or a\nbookmark replayed later)"
```
## Boundaries and interfaces
- **`rssdy::Client`** implements `ProviderClient` like every other
provider: `PROVIDER_ROOT = "/rss"`, `init(&str) -> Settings` with
write-back, and the node/track methods. Nothing new at the boundary.
- **`rssdy::api::Feeds` trait** wraps all network access (one method:
fetch and parse a feed URL), so the tree, slug, identity, and ordering
logic is unit-tested over a fake with no network — the pattern
`fyyd`/`absdy`/`soundclouddy` already use.
- **Server** — one more optional dependency and one more mount
registration. The mount registry means no dispatch code changes.
- **Clients** — one entry in each `MUTABLE_ROOTS`. No UI work: a
subscription is a queueable node like any other.
## Risks
- **A token leaking through a path or a log** is the failure that
actually matters. D1 and D4 keep URLs out of paths structurally, and
the redaction rule matches what SoundCloud and Jamendo already do for
signed media URLs.
- **Guid instability.** A publisher that regenerates guids per fetch
breaks bookmarks (the episode key changes). Nothing can fix that from
our side; `W` captures the audio, which survives it.
- **Episodes ageing out of a feed.** A bookmark to an episode the feed no
longer lists cannot resolve. Inherent to RSS; documented, and again the
reason to capture rather than bookmark things you want to keep.
- **`itunes:duration` coverage.** Durations come from whatever the feed
provides through `feed-rs`; absent duration is `None`, which the UI
already handles. To be confirmed against a real feed at implementation
time rather than assumed.
- **Feed size.** D6's byte cap is the guard; a 40 MiB feed is a bug in
someone else's publishing, not something to load into memory.
## Open questions
None blocking. Deferred by choice: OPML import/export, per-episode
played state, feed refresh in the background (there is no server-side
polling — a listing is the refresh), and `itunes:image`/artwork, which
the library model has no field for.

View File

@ -1,264 +0,0 @@
# Search via creatable library nodes
## Context and problem statement
Crabidy's library is a lazily-fetched tree served by providers; today it is
read-only and only exposes the user's favorites (playlists, followed artists).
There is no way to find anything new. The desired interface (set by the user)
is not a search dialog but **tree editing**: a search subtree under the
provider in which the user *creates* nodes. Pressing `%` inside a creatable
node prompts for a term; the term becomes a child node whose contents are the
search results. Creatable places must be visibly marked in the UI.
`tidaldy` already has a `search()` explorer stub (`search/artists`, response
dumped to the debug log), so the Tidal endpoint family
(`search/tracks|artists|albums`) is known to exist but its response shape is
unverified against our models.
## Decisions
Per the user's instruction, decisions from here on were taken autonomously and
are recorded with rationale below (Options → Decision per topic). The one
user-set constraint is the interface itself: creatable nodes in the tree, `%`
to create, terms become persistent nodes.
## Assumptions
- **Search is per-provider.** The search subtree lives at `/tidal/search`, not
at the global root; a future provider brings its own. Consistent with prefix
routing.
- **Created nodes are ephemeral**, like every other piece of server state (the
queue dies with the process, `SaveQueue` is a stub). Terms live in memory in
the Tidal client for the process lifetime. Persistence is future work.
- **"Editable" is scoped down to "creatable" for v1.** The described flow only
needs create. The RPC is named so that `RemoveLibraryNode` /
`RenameLibraryNode` can join it later without redesign (open question below).
- **Search results are a view, not a copy.** Fetched fresh on node expansion,
like playlists and albums are today. No caching, no staleness handling.
## Options considered
### 1. Mechanism: what does the wire contract look like?
**A — search-specific RPC** (`Search(query) → results`), TUI renders results
in an ad-hoc pane. Least server work, but it is exactly the interface the
user rejected: no tree nodes, nothing persistent to revisit, and a second
navigation model in the TUI.
**B — generic node creation (chosen)**: `CreateLibraryNode(parent_path,
title) → LibraryNode`, routed through `ProviderOrchestrator` by prefix like
`GetLibraryNode`. The provider decides what creation *means* under a given
parent; for `/tidal/search` the title is the search term. `LibraryNode` and
`LibraryNodeChild` gain `bool is_creatable` so any client can mark creatable
places generically. This matches the user's mental model, and the same RPC
later covers other creatable things (e.g. new playlists).
### 2. Result shape: how do results hang in the tree?
**A — full nesting**: results live entirely under
`/tidal/search/<term>/artists/<id>/<album>/<track>`. Self-contained
navigation, but `TidalPath` grows a parallel copy of every variant (search
versions of artist, album, both track kinds), `parse_path` needs recursive
suffix matching, and the same Tidal entity gets yet another path identity.
**B — canonical-path children (chosen)**: `/tidal/search/<term>` carries the
**track** results directly as its `tracks` (queueable in place, paths
`/tidal/search/<term>/<track-id>` — one new track variant), while artist and
album results are children whose paths are **canonical**
(`/tidal/artists/<id>`, `/tidal/artists/<artist>/<album>`). Diving into an
artist result reuses the existing artist/album/track machinery unchanged —
`get_lib_node(/tidal/artists/<id>)` already works for any id, favorite or not.
Cost of B, accepted and documented: after diving from a search result into an
artist, `h` (ascend) follows the canonical parent to `/tidal/artists`
(favorites), not back to the search node. This is the existing
"identity is context-dependent" trade-off from `architecture/overview.md`
pointing the other way. B wins because it adds two path variants instead of
five and cannot drift from the canonical browse behavior.
**Term nodes are not queueable** (decided during api-design): the server's
`resolve_tracks` breadth-first-sweeps *all* children of a queueable node, so a
queueable term node would turn "queue this search" into the top tracks *plus
every album of every artist result*. `is_queable = false` on the term node
keeps queueing explicit: individual track results (and any artist/album dived
into) queue normally. Queueing the term node itself yields nothing, exactly
like the existing category nodes (`playlists`, `artists`).
### 3. Term encoding in paths
Terms are user text; paths are `/`-separated and split by
`path_segments`. A term like `AC/DC` or `100%` must not corrupt the tree.
**A — reject problematic characters**: surprising, and `%`-in-term is a
plausible music query.
**B — percent-encode the segment (chosen)**: the term is percent-encoded into
the path segment (`AC/DC` → `AC%2FDC`); the node `title` keeps the raw term
for display. Round-trip helpers live next to the other path helpers in
`crabidy-core` (`encode_segment` / `decode_segment`), implemented with the
`percent-encoding` crate (tiny, maintained, already in the dependency tree via
`reqwest`/`url`).
Idempotency: creating an existing term returns the existing node rather than
erroring; empty/whitespace-only terms are rejected with
`ProviderError::InvalidInput` (new variant, also covers "this parent is not
creatable" as `NotSupported`).
### 4. TUI text entry
First text input in the TUI. **A — reuse the bindings table** by adding an
input scope: wrong tool — free text is not a finite set of chords.
**B — a modal input line (chosen)**: `%` (Library scope, only when the
*currently open* node — not the selected child — has `is_creatable`) opens a
one-line input overlay at the bottom of the library pane. While it is open the
bindings table is bypassed entirely except `Esc` (cancel) and `Enter`
(submit); every other printable char appends, `Backspace` deletes. No cursor
movement or paste handling in v1. On submit the TUI sends
`CreateNode(parent, term)`, and navigates into the returned node.
The modality mechanism generalizes what the help modal introduced: the
`(focus, help_open)` arguments of `bindings::lookup` become a single
`InputMode`-aware gate (exact shape decided in api-design). `%` itself is a
`BINDINGS` entry, so it shows up in the help modal like everything else.
### 5. Marking creatable nodes in the UI
Children with `is_creatable` render with a `%` marker suffix (e.g.
`search [%]`) in `COLOR_SECONDARY`, and when the open node itself is
creatable the pane title shows the hint (`search — % to add`). No new
keybinding needed to discover it: the marker is the affordance, the help modal
documents `%`.
## Structure
New/changed pieces, hatched by crate:
```d2
direction: right
tui: cbd-tui {
input: input overlay (new)
bindings: "% binding + input-mode gate"
library: creatable marker
}
core: crabidy-core {
proto: "proto: CreateLibraryNode rpc,\nis_creatable fields"
trait: "ProviderClient::create_lib_node (new)"
enc: "encode_segment / decode_segment (new)"
}
server: crabidy-server {
orchestrator: "ProviderOrchestrator:\nCreateLibraryNode command,\nprefix routing"
}
tidaldy: tidaldy {
search: "typed search requests (new)"
terms: "search_terms: RwLock (new state)"
paths: "TidalPath::Search, SearchTerm,\nSearchTrack (new variants)"
}
tui.input -> core.proto: CreateLibraryNode
core.proto -> server.orchestrator
server.orchestrator -> tidaldy.terms: create under /tidal/search
tidaldy.search -> core.trait: results as LibraryNode
```
Path shape after the change (green = track paths, dashed = canonical jumps):
```d2
direction: right
tidal: "/tidal"
search: "/tidal/search (creatable)"
term: "/tidal/search/<term>"
strack: "/tidal/search/<term>/<track-id>" {
style.fill: "#e8f4e8"
}
artists: "/tidal/artists"
artist: "/tidal/artists/<id>"
album: "/tidal/artists/<id>/<album-id>"
tidal -> search
search -> term: created via %
term -> strack: track results
term -> artist: artist result (canonical path) {style.stroke-dash: 3}
term -> album: album result (canonical path) {style.stroke-dash: 3}
artists -> artist
artist -> album
```
Create flow:
```d2
shape: sequence_diagram
user: User
tui: cbd-tui
rpc: gRPC service
orch: ProviderOrchestrator
tidal: tidaldy::Client
api: Tidal REST API
user -> tui: "% inside /tidal/search"
tui -> tui: open input overlay (bindings bypassed)
user -> tui: types term, Enter
tui -> rpc: CreateLibraryNode(/tidal/search, term)
rpc -> orch: ProviderCommand::CreateLibraryNode
orch -> tidal: create_lib_node(parent, title)
tidal -> tidal: store term (idempotent)
tidal -> api: "search/tracks|artists|albums?query=term"
api -> tidal: results
tidal -> orch: "LibraryNode /tidal/search/<enc(term)>"
orch -> rpc: node
rpc -> tui: node
tui -> tui: ReplaceLibraryNode (navigate into results)
```
## Boundaries and interfaces (high level)
- **proto**: `rpc CreateLibraryNode(CreateLibraryNodeRequest) returns
(CreateLibraryNodeResponse)`; request = `parent_path`, `title`; response =
the created `LibraryNode`. `LibraryNode.is_creatable = 7`,
`LibraryNodeChild.is_creatable = 4` — additive, wire-compatible.
- **ProviderClient**: new required method `create_lib_node(parent_path,
title) -> Result<LibraryNode, ProviderError>`. `ProviderError` gains
`NotSupported` and `InvalidInput` variants. The orchestrator's synthetic
root and the server's mock provider return `NotSupported`.
- **ProviderCommand**: new `CreateLibraryNode { parent_path, title,
result_tx }`, same bounded(1)-reply pattern and 30s-timeout discipline as
the existing commands.
- **tidaldy**: `TidalPath::{Search, SearchTerm, SearchTrack}`; typed
`search_tracks/search_artists/search_albums` (first page, limit 20 per
category — search is exploratory, not exhaustive; `make_paginated_request`'s
fetch-everything loop is wrong for it); `search_terms:
RwLock<Vec<String>>` following the existing login-state locking discipline
(never held across await).
- **cbd-tui**: input overlay state on `App`; `MessageFromUi::CreateNode`;
`rpc.rs` client method; `%` in `BINDINGS` (Library scope); creatable marker
in the library list rendering.
## Risks
- **Unverified search response shape.** Our `Track`/`Artist`/`Album` models
may not match `search/*` payloads (the explorer stub exists precisely
because this was unexplored). Mitigation: first implement task runs the
explorer request against the live API and locks the models down; if the
shapes differ, only `tidaldy::models` grows search-specific wrappers.
- **Trait change ripples.** Adding a required `ProviderClient` method touches
the server's mock provider and any test doubles. Deliberate: a default
"not supported" impl would hide missing implementations silently.
- **Input overlay vs. terminal reality.** Paste arrives as a burst of char
events (fine: they append), IME composition is untested. v1 accepts this.
- **Term nodes are invisible to other clients** until they re-fetch
`/tidal/search` — there is no library update stream. Accepted; browsing is
pull-based today.
## Open questions
- Delete/rename of created nodes (`RemoveLibraryNode`?) — the RPC family and
`is_creatable` flag anticipate it; not in v1.
- Should created terms persist across restarts (they'd fit a small TOML next
to the token store)? Deferred with the queue-persistence question.
- Combined-search ranking: v1 shows tracks, then artists, then albums in
fixed category order; relevance interleaving would need the combined
`search` endpoint.

View File

@ -1,249 +0,0 @@
# Seek within the playing track
## Context
Playback exposes track-level controls only: `TogglePlay`, `Next`, `Prev`,
`RestartTrack`. There is no way to move inside a track, which hurts most
where tracks are longest — the podcast providers (`/rss`, `/fyyd`) and
audiobooks (`/abs`), where "I missed that sentence" and "skip the ad" are
the two most common wishes.
The audio engine can already do it: `PlayerEngine::seek_to` and
`PlayerEngineCommand::SeekTo` exist and are wired through
`Player::seek_to`. **Nothing calls them.** There is no RPC, no playback
command, no binding — and the one implementation that exists carries a
panic (D5). So this feature is almost entirely *wiring*, plus a decision
about where the arithmetic lives.
Goal: a step of about 15 seconds forward and backward, from every client.
## Assumptions
Stated explicitly and settled by reading the code rather than by asking:
- **A1 — Within the current track only.** A backward seek at 3 s lands at
0, it does not step into the previous track; `<` / `Ctrl-p` are the
track-level controls and stay that way. Crossing tracks would need the
queue lock and the previous track's duration, for a gesture nobody
expects to do that.
- **A2 — The server owns the position.** Clients render `TrackPosition`
from the update stream and never predict it, exactly as they do for
volume and play state. A seek therefore needs no optimistic UI and no
rollback when it is refused.
- **A3 — Fire-and-forget.** Like every other playback RPC, `Seek` returns
as soon as the command is queued. A refused seek is a server-side
warning, not a client-visible error (D7).
- **A4 — No autoplay.** Seeking with nothing loaded does nothing; it does
not start the queue. `TogglePlay` and `RestartTrack` are the controls
that resume an idle player.
## Options
The only real question is **where the arithmetic lives**: a seek is
relative ("15 seconds back"), but the engine seeks to an absolute
position.
### Option A — the client computes the target
`Seek(position_millis)`. Each client takes the last `TrackPosition` it
received, adds ±15 s, clamps against the duration it was told, and sends
an absolute target.
- The wire is a plain absolute seek, which a click on the progress bar
maps onto directly. (That turned out not to need it either — see D10.)
- But the base is **stale**: positions are broadcast on a 250 ms tick and
then cross the network. The step is 15 s, so 250 ms of drift is not the
problem — **repetition** is. Press `.` three times quickly and all
three presses read the *same* broadcast position, compute the *same*
target, and the track advances 15 s instead of 45. That is the normal
way people use a seek key.
- Every client re-implements clamping, so the end-of-track and
unknown-duration edges have to be right in three places (TUI, web,
CLI) instead of one.
- While **paused** no position updates arrive at all (the tick loop skips
a paused sink), so a client's base position goes stale the moment you
pause, and a paused seek would be computed from wherever playback
stopped rather than from where the last seek left it.
### Option B — the client sends a delta *(chosen)*
`Seek(delta_millis)`, signed. The engine adds it to the live sink
position, clamps, and seeks.
- Repeated presses compose exactly: each one reads the position the
previous one produced, because the engine is a single-threaded command
loop and `try_seek` updates the position before returning.
- Clamping policy lives in one place, next to the duration the engine
already tracks.
- Clients get simpler, not more complex: a constant and one RPC call.
- Cost: absolute seek is not on the wire. A compatible proto3 addition if
something ever needs it; click-to-seek did not (D10).
**Decision: Option B.** The composition argument decides it — Option A is
wrong in the ordinary case of pressing the key twice.
## Decisions
- **D1 — The delta crosses the wire; the engine accumulates.** Clients
send a signed offset, never a target.
- **D2 — One new RPC, relative only.** `Seek(SeekRequest) -> SeekResponse`
with `sint32 delta_millis = 1`. Milliseconds because `TrackPosition`
already speaks milliseconds, so a future sub-second step needs no wire
change; `sint32` because zigzag encoding keeps negatives at one byte.
Absolute seek stays deferred — adding a field later is compatible.
- **D3 — The step size is a client constant.** `SEEK_STEP` = 15 s, one
named constant per client. The wire carries milliseconds, so making the
step configurable later is a client-only change.
- **D4 — Clamping.** Backward past the start lands at 0. Forward past the
end clamps to `duration - 1 s` when the duration is known, so the track
runs out through the ordinary end-of-stream path and advances to the
next one; the engine never has to seek *to* the exact end, whose
behaviour differs per decoder. With an **unknown** duration (a
length-less stream reports 0) there is no upper clamp — the decoder
decides, and a refusal is just a warning.
- **D5 — Fix the panic in `seek_to`.** It currently does
`time.clamp(Duration::from_secs(1), duration)`. `Ord::clamp` asserts
`min <= max`, and `duration()` yields **0** whenever the duration is
unknown (HLS, some network streams) — so that call panics the engine
thread, taking audio down with it, on any duration-less source. It is
unreachable today only because nothing calls `seek_to`; wiring seek
makes it reachable from **user input**, which the hard rules forbid.
Replaced by saturating arithmetic with no assertions. The 1-second
floor also went: landing at 0 is exactly what a backward seek near the
start means.
- **D6 — The engine reports the new position immediately.** After a
successful seek it emits `PlayerMessage::Elapsed` rather than waiting
for the next 250 ms tick. This is not just about latency: `tick()`
returns early for a paused or empty sink, so without this a seek while
paused would leave every client showing the old position until playback
resumed.
- **D7 — Unseekable sources warn and change nothing.** HLS (SoundCloud)
is decoded with `seekable = false` precisely so symphonia never seeks
it, and `try_seek` reports `NotSupported`. The server logs a warning
and broadcasts nothing; clients keep showing the true position because
they never moved it (A2). Consistent with how `pause` and
`set_volume` failures are handled — no new status codes, no new
client-side error path.
- **D8 — Bindings: two physical keys carry all four moves.** `,`/`.` seek
15 seconds back/forward and their shifted forms `<`/`>` skip a whole
track, in the **global** scope of both clients. The pair is
self-teaching (same key, shift = bigger jump), `<`/`>` are the marks
engraved on those keys, and they match mpv's playlist controls. Settled
after two rounds: `,`/`.`, then `Ctrl-b`/`Ctrl-f` at the user's request,
then back once `<`/`>` earned their place for a separate reason (below).
The deciding property is that these are **plain printable characters**,
so they collide with nothing a browser reserves. `Ctrl-n` — the
long-standing "next track" — cannot be claimed in a browser at all:
Chrome and Firefox handle it as "new window" above the page, where
`preventDefault` cannot reach, unlike `Ctrl-f`, `Ctrl-p` or `Ctrl-b`.
Reaching for the reserved-chord list is a trap the alphabetic keys
avoid entirely. `Ctrl-n`/`Ctrl-p` stay bound as the terminal's primary
chords (and `Ctrl-p` works in the browser too); the web help documents
`<`/`>`, the ones that always work.
- **D9 — No feature flag.** Seek is a few lines in the engine and one RPC
arm; it carries no dependency of its own, so by the rule in
`architecture/build-features.md` ("a feature must pay for itself in
dependencies") it does not earn one.
- **D10 — The web progress bar is clickable, still as an offset.** A
click maps the pointer's x within the gauge to a fraction of the
duration and sends `target - position`. Relative is *right* here even
though the gesture is absolute: the position it subtracts is the one
drawn on the bar the user just aimed at, and it is at most one 250 ms
tick stale — far under one pixel of the bar for any track worth seeking
in. (The same staleness is fatal for a repeated *key*, which is why
keys send a fixed step; see the Options section.) So click-to-seek
needs no absolute field on the wire after all. The arithmetic lives in
`state.rs` as a pure function, so it is unit-tested on the native
target rather than only in a browser; geometry comes from
`current_target` because the click may land on the fill rather than the
track; a duration of 0 declines the click, since a bar with no scale
has no position to click at.
The web transport bar also gets `⏪`/`⏩` buttons, and the CLI gets
`cbd global seek <SECONDS>`, which accepts negatives.
## Structure
```d2
direction: right
clients: Clients {
tui: cbd-tui\n`,` / `.`
web: cbd-web\n`,` / `.` / ⏪⏩
cli: cbd-cli\nglobal seek N
}
server: crabidy-server {
rpc: RpcService::seek
loop: playback loop\nPlaybackCommand::Seek
fwd: poll_play_bus
}
engine: audio-player {
player: Player::seek_by
eng: PlayerEngine\nseek_by -> seek_clamped
sink: rodio Player\ntry_seek
}
clients.tui -> server.rpc: Seek{delta_millis}
clients.web -> server.rpc: Seek{delta_millis}
clients.cli -> server.rpc: Seek{delta_millis}
server.rpc -> server.loop: bounded channel
server.loop -> engine.player: seek_by(delta)
engine.player -> engine.eng: SeekBy(delta, reply)
engine.eng -> engine.sink: try_seek(clamped target)
engine.eng -> server.fwd: PlayerMessage::Elapsed
server.fwd -> server.loop: PositionChanged
server.loop -> clients: TrackPosition broadcast
```
The delta stays a delta all the way down to the engine; the only place an
absolute position is computed is `PlayerEngine::seek_by`, which is also
the only place that knows the live sink position and the track duration.
## Boundaries
- **`audio-player`** gains `Player::seek_by(delta_millis: i64)` and
`PlayerEngineCommand::SeekBy`. The clamping helper is private; both
`seek_to` (absolute, kept for the deferred extension) and `seek_by` go
through it, so there is one clamping policy, not two.
- **`crabidy-core`** gains the `Seek` RPC and its two messages.
- **`crabidy-server`** gains `PlaybackCommand::Seek { delta_millis }` and
the RPC arm. The playback loop only forwards; it holds no seek state.
- **Clients** gain an action, a binding, an RPC wrapper, and a constant
each. No client-side position arithmetic anywhere (A2, D1).
## Risks
- **`try_seek` blocks the engine thread** for up to ~5 ms (it waits for
the audio callback to pick the order up). That thread already blocks
for up to 30 s opening a network stream, so this is not a new class of
stall — but it does mean seek is serialized behind an in-flight track
open, which is correct anyway.
- **Seeking while paused** relies on rodio's `periodic_access` sitting
*outside* `pausable` in the chain: a paused sink keeps being polled for
silence, so the seek order is still picked up. Verified in rodio 0.22.2
(`src/player.rs`); if a future rodio inverts that order, a paused seek
would block until unpause. Worth re-checking on a rodio bump.
- **Network-backed sources** seek inside `stream_download`'s temp
storage. A seek outside the downloaded window triggers a fresh range
request, so a long forward seek can stall audio briefly. Bounded by the
existing HTTP timeouts; no new failure mode.
- **A 15-second step on a very short track** always lands in the clamp,
which is why the clamp has to be arithmetic that cannot assert (D5).
## Deferred
Recorded, not dropped:
- **Absolute seek** (`position_millis`). Click-to-seek shipped without it
(D10), so the only remaining use would be a client that wants to name a
position without knowing the current one. A compatible proto3 addition
if that ever appears.
- **Configurable step size** in the client configs, and a larger step on
`<`/`>` (same physical keys, shifted). Client-only once wanted.
- **Chapter-aware seek** for podcasts and audiobooks. No provider exposes
chapter marks through the library model today.

View File

@ -1,302 +0,0 @@
# soundcloud provider (streaming music)
## Context and problem statement
A new library provider mounted at `/soundcloud` that lets a user **search,
resolve share links, and play** SoundCloud tracks and playlists — and, **when
they opt in with a token, browse their own likes and playlists**.
- Like fyyd/tidal/youtube, SoundCloud is a **remote, search-driven** service.
Unlike them, SoundCloud offers **no official public API**: the modern
`api-v2.soundcloud.com` requires a `client_id` that SoundCloud embeds in its
web app and **rotates periodically**, and personal-account access needs an
**OAuth token**. So the provider must (a) obtain a `client_id` on its own and
survive rotation, and (b) treat login as **optional** — public browse + play
works with only a `client_id`; a token merely adds personal nodes.
- Content is organized as **tracks** and **playlists** (a playlist is a
container of tracks). There is no per-track container level like abs books —
the tree is `search-term → tracks`, `playlist → tracks`, and (logged in)
`likes → tracks` / `playlists → playlist → tracks`, plus a **resolve** entry
that turns a pasted permalink URL into a track or playlist.
- **Playing** a track is the biggest divergence from every existing provider.
SoundCloud does not serve a plain file URL: each track carries a set of
`media.transcodings`, and the playable ones are **HLS** — an `.m3u8` playlist
of short **mp3 segments**. crabidy's player streams a single byte source, so
this requires a new **HLS source** in `audio-player` that fetches the playlist
and streams the mp3 segments in order as one continuous mp3 (mp3 frames
byte-concatenate into a valid stream — the same fact `ffmpeg -c copy` relies
on). rodio's existing symphonia mp3 path then decodes it, unchanged.
- **Captures** (`W`, download) come for free once nodes serve tracks and raise
`is_downloadable`, exactly as for abs/fyyd — no wire or TUI work.
## The SoundCloud API (grounding)
Base `https://api-v2.soundcloud.com`. **Every** request carries `?client_id`
(plus `app_version`, `app_locale=en`), omitted from the cells below; personal
calls also send `Authorization: OAuth <token>`. `search/*` add
`&limit=<n>&offset=<o>&linked_partitioning=1`. Endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Resolve a permalink URL | `GET /resolve?url=<permalink>` |
| Search tracks | `GET /search/tracks?q=<t>` |
| Search playlists | `GET /search/playlists?q=<t>` |
| Track detail | `GET /tracks/<id>` |
| Playlist detail | `GET /playlists/<id>` |
| Transcoding → media URL | `GET <transcoding.url>``{"url": "<m3u8>"}` |
| (login) My likes | `GET /me/likes/tracks` (OAuth) |
| (login) My playlists | `GET /me/playlists` (OAuth) |
Objects (fields we read):
- **track**: `id` (numeric), `title`, `user.username` (→ artist), `duration`
(ms), `permalink_url`, `media.transcodings[]`, `policy`/`streamable`,
`publisher_metadata` (optional album/release).
- **transcoding**: `url` (a second API URL, not the CDN), `preset`,
`format.{protocol, mime_type}`, `quality`. We select
`protocol == "hls" && mime_type == "audio/mpeg"` (mp3-HLS), which SoundCloud
offers for essentially every playable track.
- **playlist**: `id`, `title`, `user.username`, `tracks[]` — often returned as
**stubs** (`{id}` only); missing tracks are hydrated in batches of ≤50 via
`GET /tracks?ids=<csv>&client_id=…`.
- **resolve**: returns a track or a playlist object (discriminated by `kind`).
`client_id` acquisition (no login): `GET https://soundcloud.com`, find the
referenced JS bundles, fetch them, regex `client_id:"(\w+)"`; `app_version`
from `window.__sc_version="(\d+)"`. This is exactly the streamrip approach.
## Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by `/youtube`, `/fyyd`, `/abs`): search-term + resolve semantics
cost no TUI or wire change. No proto change, no new `ProviderCommand`.
- A missing/rotated/invalid `client_id` must never crash startup or a browse.
The provider **self-heals** by scraping and by re-scraping on `401/403`; only
if scraping itself fails does the `/soundcloud` subtree degrade (typed
errors, skipped tracks), never the app.
- **Login is optional.** With no `oauth_token`, personal nodes (`likes`,
`playlists`) are simply **not shown**; public search/resolve/play still work.
A token unlocks the personal nodes and is refreshed/persisted like tidal's.
- HLS media/segment URLs and the `client_id`/`oauth_token` are **secrets or
ephemeral signed URLs**: redact from `Debug`/config dumps, never log the built
stream/segment URLs (hard rule: redact secrets from logs and error reports).
- SoundCloud `id`s are numeric (URL-safe); only user-typed **search terms** and
**pasted URLs** are percent-encoded into a path segment.
## Decisions
### D1 — Crate `soundclouddy`, mounted at `/soundcloud`, non-fatal init
New workspace crate `soundclouddy` implementing `ProviderClient`, shaped on
`fyyd`/`absdy` (remote, search-driven, plain leaf tracks). Wired into
`ProviderOrchestrator` with a `sc_client: Option<Arc<soundclouddy::Client>>`
field, `sc_owns()`/`sc_provider()` helpers, a `build()` block that reads
`soundcloud.toml` (non-fatal), a `get_lib_root` child gated on
`self.sc_client.is_some()`, and one routing arm in each dispatch method.
`crabidy-server` settings gain `soundcloud` in `ALL_PROVIDERS` (now 8), in
`ProviderToggles`, in `all()`, and in `provider_toggles()`. No `cli.rs`/
`main.rs` change (providers are pure path-prefix subtrees).
### D2 — HTTP behind a trait, faked in tests; client_id lifecycle inside the seam
All network access goes through one seam — an `Sc` trait (`resolve`,
`search_tracks`, `search_playlists`, `track_detail`, `playlist_detail`,
`hydrate_tracks`, `resolve_stream_url`, and, when logged in, `my_likes`,
`my_playlists`) behind `Box<dyn Sc>` — with a `reqwest`-based `ScApi` for
production and a `FakeApi` in tests (as `absdy` hides `reqwest` behind `Abs`).
The **client_id acquisition, caching, and re-scrape-on-401** live entirely
inside `ScApi` so provider logic (tree shaping, path parsing, term store) is
unit-tested with zero network. Errors map to `ProviderError::FetchError` at the
boundary; malformed paths → `MalformedPath`; empty create/rename → `InvalidInput`.
### D3 — Tree shape (search, resolve, optional personal)
Track ids and playlist ids are both numeric, so leaf/container paths use a
**type-tagged** canonical segment to disambiguate: `track/<id>` and
`playlist/<id>`. Browse nodes point their children at these canonical paths.
- `/soundcloud` — children: `search` (creatable), `resolve` (creatable), and —
**only if logged in**`likes` and `playlists`. Not itself queueable.
- `/soundcloud/search` / `/soundcloud/resolve``is_creatable`; children are
the in-memory terms/URLs (`RwLock<Vec<String>>`, dedup), each editable and
deletable, like tidal/youtube/fyyd/abs search terms.
- `/soundcloud/search/<term>` — matching **tracks** as queueable leaves (and,
optionally, matching playlists as containers).
- `/soundcloud/resolve/<url>` — the resolved permalink: a single track leaf, or
a playlist container.
- `/soundcloud/likes` (login) — the user's liked **tracks**.
- `/soundcloud/playlists` (login) — the user's playlists as containers.
- `/soundcloud/playlist/<id>` — a playlist's tracks (queueable, downloadable);
the canonical container path, reached from search/resolve/likes/playlists.
- `/soundcloud/track/<id>` — the canonical **track leaf**. A track id alone is
sufficient to resolve a stream, so every branch's track children point here
and playback needs no browse context.
Terms/URLs are percent-encoded into one segment (`encode_segment`/
`decode_segment`); numeric ids are already URL-safe.
### D4 — Playback: progressive mp3 (HLS source retained as fallback)
**Revised after live testing (2026-07-24).** The original plan chose HLS mp3,
but live probing showed SoundCloud's plain `hls + audio/mpeg` transcoding
exchange **404s for anonymous clients** (every track, streamable or not), while
the **`progressive + audio/mpeg`** transcoding returns 200 with a direct,
range-streamable mp3 URL (`cf-media.sndcdn.com`, `206`, `audio/mpeg`). So the
provider now **prefers progressive**, which the player streams on its normal
windowed-HTTP path — no HLS needed for the common case. The `HlsStream` built
for the original plan is kept as a fallback for any track that offers only HLS.
- `get_urls_for_track(/soundcloud/track/<id>)`: `GET /tracks/<id>`, pick the
best mp3 transcoding (`progressive` first, then `hls`), `GET
<transcoding.url>?client_id=…` → the media URL, and **return it as `urls[0]`**
(the player consumes only the first). One API round-trip — unlike abs's pure
string-building — because the media URL is signed and ephemeral. A track
whose exchange 404s (Go+/label preview, geo-blocked) surfaces
`NotStreamable`/`NotFound` and is skipped, never a crash.
- **New `audio-player` component `HlsStream`** — a `stream-download`
`SourceStream`, sibling to `WindowedHttpStream`: on create it fetches the
`.m3u8` (a media playlist), parses `#EXTINF`/segment URIs (resolving relative
URIs against the playlist URL, and following one level if handed a master
playlist); on poll it streams each mp3 segment's bytes in order, advancing at
segment boundaries, finishing after the last. The concatenated bytes are a
valid mp3 → rodio's symphonia mp3 decoder handles them unchanged.
- **Routing**: `open_source` selects `HlsStream` when the URL path ends in
`.m3u8` (SoundCloud's media URLs carry it); all other http URLs keep the
windowed-HTTP path, and content-sniffing (opus vs the rest) is unchanged
downstream. `#EXTM3U` content-sniff is a hardening fallback if needed.
- **Duration** comes from the track metadata (`duration` ms → `Track.duration`),
not from the stream, so the seek bar is correct even though the concatenated
HLS stream carries no container duration.
- Track fields: `title` = track title, `artist` = `user.username`, `album` from
`publisher_metadata` when present else `None`, `duration` = ms→`Option<u32>`,
`provider_item_id` = `"track:<id>"` (keys the capture store).
### D5 — Auth: client_id self-heal, optional OAuth login
- `Settings`: `client_id: Option<String>`, `app_version: Option<String>`
(both **cached** after first scrape and round-tripped via `settings()` so we
don't re-scrape every start), `oauth_token: Option<String>` (optional),
and bounds (D6). Hand-written `Debug` redacts `client_id`/`oauth_token`.
- **No login (baseline)**: if `client_id` is unset, `ScApi` scrapes it from
`soundcloud.com` at init; on any `401/403` it re-scrapes **once** and retries
(rotation recovery). The freshly scraped id is persisted.
- **Optional login**: if `oauth_token` is present, `get_lib_root` adds `likes`
and `playlists`, and personal calls send `Authorization: OAuth <token>`. If a
refresh-token flow is configured later it mirrors tidal's persist-on-refresh;
v1 accepts a static token and, on `401`, drops the personal subtree with a
typed error (never a crash) — public browse is unaffected.
### D6 — Bounds and freshness
- `search_results` (default 50), `playlist_tracks_limit` (default 500, hydrated
in ≤50-id batches), `call_timeout_secs` (default 30) bound each HTTP call, and
`hls_total_deadline_secs` (default 300) bounds a whole HLS fetch (segments are
retried with jitter under this deadline). Caps are `log`-ged so truncation is
visible, not silent (hard rule: no silent caps).
- Listings are fetched fresh per call (no cross-call cache), like the other
remote providers; only search terms / resolve URLs are stored in memory. The
chosen transcoding may be briefly cached per track id to save the extra
round-trip on replay.
### D7 — Out of scope (explicitly)
- **opus-HLS** (`audio/ogg; codecs=opus`) and **progressive** transcodings: v1
targets mp3-HLS uniformly (offered for ~all tracks). opus-HLS is a phase-2
add that reuses the new `OpusSource` per segment; the `HlsStream` seam makes
it additive.
- **HLS seeking**: v1 HLS is forward-only and reports the source as
non-seekable, so symphonia does not attempt an end-seek (which would need a
known byte length). In-track seek is a later add (open at a segment offset).
- Go+ / high-quality / lossless streams (need a premium account), uploads,
comments, reposts feed, waveforms, social graph, and playback-progress sync.
- Pagination past the configured caps (one page per listing).
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
sc: "soundclouddy (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms + resolve URLs\n(in-memory)"
api: "ScApi\n(reqwest seam: Sc trait,\nclient_id self-heal, opt OAuth)"
client -> terms
client -> api
}
player: "audio-player" {
hls: "HlsStream\n(new SourceStream)"
dec: "rodio / symphonia\n(mp3, opus)"
hls -> dec: "concatenated mp3 bytes"
}
scapi: "SoundCloud\napi-v2 + HLS CDN" { shape: cloud }
web: "soundcloud.com\n(HTML + JS)" { shape: cloud }
server.orch -> sc.client: "/soundcloud/..."
sc.api -> scapi: "resolve / search / tracks / transcoding (JSON, timeout)"
sc.api -> web: "scrape client_id (init, on 401)"
server.orch -> player.hls: ".m3u8 media URL"
player.hls -> scapi: "GET m3u8 + mp3 segments"
```
## Key flow: search and play a track
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
s: soundclouddy
api: "SoundCloud api-v2"
hls: "HlsStream (audio-player)"
cdn: "HLS CDN"
tui -> orch: "open /soundcloud/search"
tui -> orch: "create term \"boards of canada\""
orch -> s: "create_lib_node(search, term)"
s -> tui: "term stored"
tui -> orch: "open /soundcloud/search/<term>"
orch -> s: "get_lib_node"
s -> api: "GET /search/tracks?q=…&client_id="
api -> s: "tracks (id, title, user, duration)"
s -> tui: "tracks as queueable leaves (/soundcloud/track/<id>)"
tui -> orch: "queue + play a track"
orch -> s: "get_urls_for_track(/soundcloud/track/<id>)"
s -> api: "GET /tracks/<id> → pick hls+audio/mpeg transcoding"
s -> api: "GET <transcoding.url>?client_id= → { url: m3u8 }"
s -> orch: "urls = [ m3u8 ]"
orch -> hls: "player.play(m3u8)"
hls -> cdn: "GET m3u8 (segments)"
hls -> cdn: "GET segment 1..N (mp3, in order)"
hls -> orch: "continuous mp3 → symphonia decodes"
```
## Risks and open questions
- **client_id scraping fragility.** The scrape regexes depend on
soundcloud.com's HTML/JS shape and can break on a redesign. Mitigation: a
config override (`client_id` in `soundcloud.toml`) always wins, and failures
are typed (subtree degrades, app survives). The scrape is the one piece with
no test-double coverage of the *live* format — flagged as a **live-test gate**.
- **Ephemeral media URLs.** The resolved `.m3u8` and its segments are signed and
short-lived; playback must start promptly after resolution (like `/youtube`).
A stale URL surfaces as a skipped track, never a crash. Never logged.
- **HLS without a known length.** The concatenated stream has no total byte
length; reported non-seekable so symphonia won't end-seek (the rodio-0.22
panic the opus work documented). **Gate:** verify an end-to-end mp3-HLS play
does not panic and reaches EOS cleanly.
- **Master vs media playlist.** SoundCloud returns a media (segment) playlist
for the chosen transcoding; `HlsStream` follows one level of master playlist
defensively and picks the first variant.
- **Playlist stubs.** Playlist detail may return track stubs; hydration in
≤50-id batches is bounded by `playlist_tracks_limit`. A hydration miss drops
that track (skipped), never an error.
- **OAuth token lifetime.** v1 accepts a static token; expiry drops the personal
subtree with a typed `401` (public browse unaffected). A device-flow/refresh
upgrade mirrors tidal and is additive.
- **Field / envelope drift.** DTOs decode defensively (`#[serde(default)]`);
a renamed field is a local fix in `ScApi`. Live validation is a task-plan gate.

View File

@ -1,100 +0,0 @@
# Frequency spectrum visualizer
A row of frequency bars under the track progress in the TUI, on by
default, turn-offable in the client config. The reference the user gave,
[BeSpec](https://github.com/BeSpec-Dev/BeSpec), is a standalone
egui/wgpu app that captures *local* system-audio loopback and runs a
2048-point realfft — we borrow its DSP shape, not its capture model.
## The core problem: where does the audio live?
The audio is decoded and played **on the server** (the `audio-player`
crate, rodio). The TUI — and the web client — are gRPC clients that may
run on another machine (the stated setup: `cbd` local, `cbd-tui`
pointed at a Raspberry Pi, `architecture/client-configs.md`). A local
loopback capture in the client, BeSpec-style, would therefore show
nothing (or the wrong machine's audio) for a remote client.
So the spectrum must be produced where the samples are — the server —
and streamed to clients like every other bit of live state.
## Decisions
### D1 — server taps the samples, computes the FFT, streams bins
- **Tap** (`audio-player`): the decoded source is wrapped in a
`TappingSource` that copies each played frame (downmixed to mono)
into a fixed lock-free ring (`SpectrumTap`, 2048 `f32` slots, atomic
write index). It runs on rodio's audio thread, so it does the
absolute minimum — one store per sample, no locks, no allocation —
and benign read/write races are fine for a visualizer.
- **FFT** (`crabidy-server`): a task ticks at ~20 fps, snapshots the
ring, applies a Hann window + realfft, folds the magnitude spectrum
into a small number of log-spaced bins (musically even), normalizes
to `0..1`, and broadcasts them.
- **Stream**: a new `SpectrumFrame { bins }` on the existing
`GetUpdateStream` (oneof variant), at a handful of bins × ~20 fps —
~2 KB/s, negligible next to the audio it describes.
Rejected: client-side loopback capture (breaks the remote client, the
whole reason clients exist); sending raw PCM to clients (orders of
magnitude more bandwidth, and every client would re-run the FFT).
### D2 — idle detection without touching the control path
The tap increments a frame counter on every write. The FFT task
compares the counter between ticks: advancing ⇒ audio is flowing, emit
bins; unchanged ⇒ paused/stopped/between tracks, emit a single
all-zero frame (bars fall to the floor) and then stay quiet until it
moves again. No extra `is_playing` round-trips onto the player command
channel, and no frozen bars on pause.
### D3 — the config toggle is client-side display; the server always offers it
`spectrum = true` (default) in `cbd-tui.toml` / `cbd.toml` shows the
bars; `false` hides them. The toggle is a *display* choice — the
server always computes and streams when something is playing. At
household scale one small FFT task at 20 fps is not worth gating on a
per-client preference, and keeping the server unconditional means any
client (TUI, web) can show bars without a negotiation. The FFT only
runs while audio is actually flowing (D2), so an idle server is idle.
### D4 — rendering
The TUI draws the bars in the now-playing pane, directly under the
progress gauge, as a single row of vertical block glyphs
(`▁▂▃▄▅▆▇█`) whose heights track the bins, in the accent color. The web
client renders the same bins as CSS-height bars for parity. Both simply
consume the latest `SpectrumFrame`; neither computes anything.
## Structure
```d2
direction: right
audio: "audio-player (server)" {
dec: decoder
tap: "TappingSource\n→ SpectrumTap ring"
sink: rodio sink
dec -> tap -> sink
}
fft: "spectrum task\nHann + realfft →\nlog bins, ~20fps"
stream: "GetUpdateStream\nSpectrumFrame{bins}"
tui: "TUI now-playing\nblock-glyph bars"
web: "web client\nCSS bars"
audio.tap -> fft: snapshot
fft -> stream
stream -> tui
stream -> web
```
## Risks
- **Audio-thread cost**: the tap must stay trivial; anything more than
a store per sample risks underruns. No locks, no allocation, no
logging on that path.
- **Torn reads**: the FFT reads the ring while the audio thread writes
it. Accepted — a visualizer tolerates the occasional stale/mixed
sample; correctness of playback is never affected (the tap only
*observes*).
- **realfft** is pure Rust (no system libs), so it does not complicate
packaging.

View File

@ -1,56 +0,0 @@
# TUI `/` search filter
Pressing `/` in the library or queue pane opens a search input that
filters that pane's items live as you type (case-insensitive
substring). A small feature; recorded here for the couple of decisions
that were not obvious.
## Decisions
### D1 — filter, not jump
Vim's `/` jumps to the next match; here `/` *narrows* the visible list
to matching rows. For a music library ("show me everything with
'radiohead'") filtering is the more useful reading of "search in the
items", and it composes with the existing queue/mark actions — you
filter, then act on what is left.
### D2 — the filter lives on the pane, the input on the app
The pane (`Library`/`Queue`) owns a `Filter` (the query plus the list
of visible real indices). The `/` input mode (`App::search`) only holds
the editing buffer and which pane is focused. So the filter *survives*
closing the input: `Enter` keeps it applied and returns to navigation,
`Esc` clears it. This matches the other modal overlays (input, confirm)
in how keys are routed while it is open.
### D3 — view indices vs real indices
The panes keep their full item list; the filter maps between the
*view* index (what the selection bar and `StatefulList` navigation key
off) and the *real* index into the list. This matters most for the
queue: removal and set-current send **real queue positions** to the
server, so a filtered selection must map back before it is sent —
otherwise `d` on the third visible row would remove the wrong track.
Marks likewise live on the full list, so a marked-but-hidden item still
counts when queueing.
Because `StatefulList` already routes through `get_size`/`select`/
`selected`, pointing those at the filtered view made all the movement
keys (`j`/`k`/`g`/`G`/`Ctrl-d`/`Ctrl-u`) work on the filtered list with
no per-key changes.
### D4 — lifecycle
- **Library**: entering a node is a fresh listing, so search mode is
reset there (a stale filter from the previous folder would be
confusing).
- **Queue**: the queue is re-sent constantly (position ticks,
resolving), so an active search is *preserved* across updates and
only its visible set is recomputed.
## Scope
Implemented in the TUI only, per the request. The web client
(`architecture/web-client.md`) could mirror it later for parity; noted
as a follow-up, not done here.

View File

@ -1,157 +0,0 @@
# TUI visual mode (paint-select with movement)
## Context and problem statement
The library pane supports **marks**: `s` toggles the selected row's mark (gated
on `is_queable`), and the multi-item actions (`a` append, `L` queue-next, `Enter`
replace, `w`/`W` capture… — via `get_selected`) operate on the marked set,
falling back to the bare selection. Marking a contiguous run today means pressing
`s`, moving, `s`, moving, `s`… — one keystroke per row.
The request: a vim-style **visual mode**. Press `v` (and `V` — both do the same)
to enter it; then **movement toggles the mark of the rows it sweeps over**, so a
run is selected by `v` then `j j j` (or `v G`). Pressing `v`/`V` again — or `Esc`
— leaves visual mode; the marks persist for the next action.
This frees no key, so the **spectrum toggle currently on `v` must move**
(architecture/spectrum.md; it is a client-only display toggle).
## Assumptions (decided here)
- **Library-only.** Marks exist only in the library pane; the queue's marks are
a standing `FIXME` (`queue.rs`), so visual mode binds in the **Library scope**
only. Queue visual mode is out of scope until the queue grows marks (D5).
- **Marks are the selection.** Visual mode is pure UI over the existing
`UiItem.marked` — no new wire types, no server calls, no proto change. It only
changes *how* marks get toggled.
- **`is_queable` gating is preserved.** `toggle_mark` only marks queueable rows;
paint-toggle does the same, so sweeping over a non-queueable row leaves it
unmarked (consistent with `s`).
- TUI-only, like `tui-search`. Web-client parity is a follow-up (D6).
## Decisions
### D1 — `v`/`V` enter visual mode; spectrum moves to `f`
Two new Library-scoped bindings, `v` (`NONE`) and `V` (`SHIFT`), both mapping to
one new `Action::LibraryVisualMode` (same two-binding/one-action pattern as
`K`/`J`, `W`, etc.). `Action::ToggleSpectrum` moves from Global `v` to **Global
`f`** ("frequency"), a key free in every scope. *This letter is a pure
preference — trivially changed in `bindings.rs`; `f` is the chosen default.*
### D2 — Paint-toggle semantics: sweep toggles, endpoints included
Visual mode holds one piece of state — that it is **active** (the anchor is
the **anchor** — the view index where `v` was pressed — plus the cursor).
Behavior:
- **Enter** (`v`/`V` while normal): activate, **anchor at the current row**, and
**toggle its mark** (vim includes the row you start on). A lone `v … v` thus
behaves like a single `s`.
- **Move** (any of `j`/`k`, `g`/`G`, `Ctrl-d`/`Ctrl-u` while active): perform the
move, then reconcile marks to the contiguous **range `[anchor, cursor]`**
toggle exactly the rows whose membership in that range **changed** (relative to
the anchor). Growing the range marks the rows entered; **shrinking it unmarks
the rows left**, so moving back down/up **cleanly reverses** a move and the row
you turn around on is never stranded. Jumps (`G`, `Ctrl-d`) reconcile the whole
span at once. It is a *toggle* against range membership, so sweeping over a
row that was already marked (by `s`) flips it, and sweeping back flips it back.
- **Exit**: `v`/`V` again, or `Esc`, deactivates (marks persist). Any other
action key (`a`, `Enter`, `w`, …) also **exits first, then runs normally**, so
`v j j a` selects three rows and appends them. Changing node (`h`/`l`) or focus
(`Tab`) also exits — the anchor/indices would otherwise be stale.
```d2
direction: right
shape: sequence_diagram
Normal
Visual
Normal -> Visual: "v / V (anchor here, toggle current row)"
Visual -> Visual: "j k g G C-d C-u (reconcile marks to [anchor, cursor])"
Visual -> Normal: "v / V / Esc (marks kept)"
Visual -> Normal: "a / Enter / w / … (exit, then act on marks)"
Visual -> Normal: "h / l / Tab (node/focus change)"
```
Worked example (rows `0..9`, all unmarked, cursor at `0` = anchor):
```text
v -> {0} anchor 0, toggle current
j -> {0,1} range [0,1]
j -> {0,1,2} range [0,2]
G -> {0..9} range [0,9] (jump reconciles the span)
k -> {0..8} range [0,8] -> 9 leaves, unmarked
g -> {0} range [0,0] -> 1..8 leave, unmarked
v -> exit, marks {0} kept
```
### D3 — Where the state lives and how dispatch routes it
`Library` owns the visual state as `visual: Option<usize>``Some(anchor_view)`
while active, so the mode and its anchor are one field, exposed as `is_visual`,
`toggle_visual`, `exit_visual`. `App::dispatch` is the single choke point (it
already maps every `Action`):
- `LibraryVisualMode``library.toggle_visual()` (activate: anchor at the cursor
and toggle its mark; or deactivate). Only reachable while the library is
focused (Library-scope binding).
- The six movement actions → a `library_move` helper: when visual is active, read
the cursor, run the existing move, read the cursor again, and call
`Library::paint_between(old_view, new_view)`; otherwise just move.
- `LibraryAscend`/`LibraryDive`/`CycleFocus` → covered by the catch-all below
(they exit visual, then proceed).
- `ClearSearch` (`Esc`) → if visual is active, just leave it (do not also clear
the search filter); else unchanged.
- Every other action → leave visual mode first, then proceed. Implemented as a
guard at the top of `dispatch`: capture `was_visual`, and exit unless the
action is one of the six movements, `LibraryVisualMode`, or `ClearSearch`.
`Library` gains `selected_view()` (the current view index) and
`paint_between(from_view, to_view)` — using the stored `anchor`, toggle the mark
(respecting `is_queable`) of every view index whose membership in `[anchor,
cursor]` changed between the old and new cursor, mapping each through the `/`
filter to its real index exactly as `toggle_mark` does. No change to `select`, so
non-visual selection (filter re-select, `update_selection`) never paints.
### D4 — Visual indicator
The library pane title shows `— VISUAL` while active (same title slot as the
`— /query▏` search hint and `— % to add`). The help modal lists `v`/`V`
("Enter visual mode: movement toggles marks") in the Library group and the moved
`f` spectrum toggle in the Global group — both derived from `BINDINGS`, so they
stay correct for free.
### D5 — Out of scope: queue visual mode
The queue has no marks, so `v`/`V` are unbound there (a no-op). Extending visual
mode to the queue is gated on giving the queue a mark set (the existing
`queue.rs` `FIXME`) and is left for that work.
### D6 — Out of scope: web-client parity
`cbd-web` mirrors the TUI keymap; a visual mode there is a clean follow-up (its
`state.rs`/`keymap.rs` are the analog seams), not part of this TUI change.
## Boundaries / interfaces
- **`bindings.rs`** (pure data): `+LibraryVisualMode`, its two Library bindings,
and the `ToggleSpectrum` chord moves `v`→`f`. All dispatch/help/label logic is
already derived from the table.
- **`app/mod.rs`** (`App`): the `was_visual` guard + `library_move` + the
dispatch routing above.
- **`app/library.rs`** (`Library`): the `visual: Option<usize>` anchor,
`is_visual`/`toggle_visual`/`exit_visual`, `selected_view`, `paint_between`, and
the title indicator. Marks, filter, and `select` are reused unchanged.
## Risks and open questions
- **Anchor semantics chosen over per-step toggle.** An earlier half-open
per-step design stranded the turnaround row (down-then-up left the furthest row
marked). The anchored `[anchor, cursor]` range reconciliation fixes that:
moving back cleanly reverses. Sweeping over a pre-existing (`s`) mark still
*toggles* it against range membership — reversible, but worth knowing.
- **Filter interaction.** With a `/` filter active, paint sweeps **view** indices
and toggles their real rows, so only visible rows are affected — consistent
with how `s` and `get_selected` already treat marks vs. the filtered view.
- **Stale indices on list change.** Any node/focus change exits visual mode, so
a reload can never paint against a previous list.

View File

@ -1,192 +0,0 @@
# Web client (cbd-web)
A browser client with the same functionality as the TUI, served by
`crabidy-server` itself so that "open `http://server:50051`" is the
whole install story. Leptos, pure modern CSS, crab orange-red accent,
light and dark themes.
## Context and problem statement
The TUI covers the owner's desk. Phones, tablets, and guests (see
`architecture/roles-auth.md` — queue-owner / queue-appender roles
exist precisely for them) need a client without a terminal. It must
not be a second, drifting implementation of the protocol surface: the
web client should speak the same gRPC contract as the TUI, feature for
feature: library browsing, search terms, queue manipulation, playback
control, bookmarks (`w`), captures (`W`, with progress and confirmed
deletion), and the live update stream.
## Assumptions (confirmed or decided)
- "Exactly the same functionality as the TUI" means the same *actions
and information*, not a terminal emulation: every TUI binding has a
clickable equivalent, and the familiar keyboard bindings (j/k/h/l,
Tab, %, e, d, w, W, …) also work on desktop browsers.
- "Local first" is interpreted for what this app *is* — a remote
control for live server state (one audio output, one queue). There
is no offline-editing story to sync: a CRDT layer (as in the
`web_client_example_workspace` template, automerge et al.) would
model conflicts that cannot occur and add a heavy dependency wall.
Local-first here means: **client-side rendered, all assets local
(no CDN), session state and credentials in the browser, library
listings cached in memory like the TUI's, optimistic UI where
safe, and graceful reconnect/backoff when the server disappears.**
This is a deliberate, documented deviation from the example
template.
- The example workspace informs the toolchain (leptos 0.8, trunk,
wasm32 target in devenv, workspace layout, lint posture) — not the
runtime architecture (SSR/hydration + WebSocket sync). We build a
pure CSR app: the server side must stay tonic, not become a leptos
SSR host.
- One port for everything: gRPC (TUI), gRPC-web (browser), and static
assets are all served on `LISTEN_ADDR` (50051). No second listener,
no CORS story needed (same origin).
## Options considered
### Browser transport
1. **gRPC-web with the existing proto** — server wraps the existing
tonic service in `tonic-web` (0.14.6, matches our tonic); the
browser uses the *same generated clients* from `crabidy-core` over
`tonic-web-wasm-client` (0.9.1, tonic ^0.14). Server streaming
(GetUpdateStream) is supported. The 24-RPC surface and all types
are shared — parity is structural, not aspirational. The auth
layer keeps working unchanged: gRPC-web POSTs to the same
`/crabidy.v1.CrabidyService/…` paths, `minimum_role` sees them
identically, and the browser can set the `authorization` header.
2. REST + WebSocket bridge — a second API surface to hand-write,
secure, and keep in sync. Rejected.
**Decision: gRPC-web (1).**
### Serving the app
1. **Embed the built assets in the server binary** (`include_dir` of
`cbd-web/dist`) behind a cargo feature `web-ui`, **default on**
(the request), compiled into `crabidy-server` and thus `cbd`. The
single binary stays self-contained.
2. Serve from a directory on disk at runtime. Flexible but breaks the
single-binary story and invites path confusion. Rejected (can be
added later as an override).
**Decision: embed (1).** tonic 0.14's router is axum-based:
`Routes::into_axum_router()` lets us add plain axum routes for `/`,
`/pkg/…` and friends next to the gRPC paths. Static assets are served
without authentication (the app shell is public; every RPC behind it
stays gated) — same posture as any login page.
### Building the wasm app
`cbd-web` is a workspace member built by **trunk** into
`cbd-web/dist`. Embedding happens through a small
`crabidy-server/build.rs` that copies `cbd-web/dist` into `OUT_DIR`
when present and otherwise generates a **placeholder page** ("web UI
not built — run `devenv shell -- trunk build --release` in
`cbd-web/`") so that:
- plain `cargo build` never fails and never needs wasm tooling
(default-on feature stays harmless),
- `build.rs` never invokes cargo-in-cargo (trunk runs cargo; nesting
it inside a build script risks target-dir lock deadlocks),
- rebuilding after a trunk run re-embeds automatically
(`rerun-if-changed=cbd-web/dist`).
devenv gains `trunk`, `wasm-bindgen-cli`, `binaryen` and the
`wasm32-unknown-unknown` rustup target, so the documented build is
two commands. The dev loop (`trunk serve` with a proxy to a running
server) is documented in `cbd-web/README.md`.
### crabidy-core on wasm
The generated gRPC client must compile to `wasm32-unknown-unknown`.
`crabidy-core` trims its tonic dependency to
`default-features = false, features = ["codegen"]` (no transport, no
router); native crates keep the full tonic via their own dependency
edges, and cargo's per-target feature unification does the rest.
Native-only pieces of crabidy-core that do not build on wasm (config
loading via `dirs`, clap plumbing) move behind a
`cfg(not(target_arch = "wasm32"))` gate / target-specific
dependencies. The proto types, paths helpers, and client stubs are
the wasm surface.
## Structure
```d2
direction: right
browser: Browser {
app: "cbd-web (leptos CSR wasm)"
store: "localStorage:\ncredentials, theme"
app -> store
}
server: "crabidy-server :50051" {
axum: axum router
static: "embedded cbd-web/dist\n(feature web-ui, default on)"
grpcweb: "tonic-web layer"
auth: AuthLayer
rpc: CrabidyService
axum -> static: "GET /, /pkg/…"
axum -> grpcweb: "POST /crabidy.v1.…"
grpcweb -> auth -> rpc
}
tui: cbd-tui
browser.app -> server.axum: "gRPC-web (fetch,\nauthorization header)"
tui -> server.axum: gRPC (HTTP/2)
```
```d2
direction: right
title: cbd-web internals {near: top-center}
rpc: "rpc.rs\ntonic-web-wasm-client,\nsame crabidy-core stubs"
state: "state.rs\nsignals: queue, play\nstate, volume, library\n+ cache, captures"
stream: "stream task\nGetUpdateStream →\nsignals, reconnect backoff"
ui: "components\nLibrary, Queue, NowPlaying,\ndialogs (name, y/N, login)"
keys: "keymap\nTUI-compatible bindings"
rpc -> stream -> state
ui -> rpc: actions
state -> ui: render
keys -> ui
```
## Functional parity map (TUI → web)
| TUI | Web |
| --- | --- |
| library j/k/h/l, Tab, Enter | list + click/keys, back button, panes |
| `%` create search term | "+" affordance & `%` key → name dialog |
| `e` rename, `d` delete (+capture y/N) | item actions & keys → dialogs |
| `w`/`W` bookmark/capture + progress | keys/actions → dialog, progress |
| marks (`*`), queue/append/replace/insert | multi-select & keys |
| queue ops, x remove, C/c clear, s save | buttons & keys |
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
| skipped tracks red | same, via `Track.is_skipped` |
| update stream reconnect | same, backoff + disconnect banner |
| auth via config file | login form (proactive + forced), localStorage |
| `?` help modal | `?` help overlay listing keys |
## Styling
Pure hand-written CSS (one `style.css`, no framework, no CDN):
custom properties for the palette, `color-scheme: light dark` +
`light-dark()`/`prefers-color-scheme` with a manual override toggle
(persisted), CSS nesting, `color-mix()` for derived tones, grid/flex
layout, `@media` breakpoints for phone layout (library and queue as
switchable panes, like Tab in the TUI). Accent color "crab
orange-red": `--accent: oklch(0.62 0.19 35)` (≈ #e14b2a) with hover /
active derivations via `color-mix`. Focus rings and selection bars
reuse the accent; skipped tracks and destructive confirms use the
existing red semantics.
## Risks and open questions
- `tonic-web-wasm-client` is a third-party crate; if it ever lags a
tonic bump, the pinned pair (tonic 0.14 / 0.9.1) keeps building —
upgrade both in lockstep.
- gRPC-web server streaming holds one HTTP connection per browser
tab; fine at household scale.
- The browser cannot play the audio (output is the server's
speakers); a later "play in browser" feature would need a separate
audio streaming endpoint — explicitly out of scope.
- Leptos component logic is hard to unit-test headlessly; logic that
matters (path/selection state machines, formatting) lives in plain
modules with native `#[test]`s, components stay thin.

View File

@ -1,194 +0,0 @@
# YouTube provider (ytdy)
> **Extraction engine superseded** by `youtube-rustypipe.md`: the
> `yt-dlp` subprocess (D1, D5, the `binary` setting, and the
> `bestaudio` format choice) was replaced with the pure-Rust
> `rustypipe` client after playback turned out broken (bestaudio =
> Opus, which the rodio/symphonia player cannot decode) and the Python
> subprocess proved unwanted. The tree shape, search-term store, path
> scheme, and optional-login gating described here still hold.
## Context and problem statement
A new library provider for YouTube, mounted at `/youtube`:
- **Search** works without any login — creatable search-term nodes like
`/tidal/search` (`%` creates a term, results list as tracks).
- **Login is optional.** When configured, the user's **playlists** appear
as an extra subtree; without it, the provider still works (search
only).
- **Captures** (`W`, download) must work on YouTube nodes — search
results and playlists are downloadable.
## Assumptions (confirmed)
- The captures machinery is provider-agnostic: anything that answers
`get_lib_node`/`get_urls_for_track` and raises `is_downloadable` gets
`w`/`W` for free — no new wire or TUI work at all.
- The TUI's creatable/editable/deletable node flows (`%`/`e`/`d`) are
generic; mirroring tidal's search-term semantics costs no TUI change.
- `pkgs.yt-dlp` (2026.06.09) exists in nixpkgs; the devenv already pins
the toolchain, so the engine binary is declared, not assumed.
- The audio player streams plain https URLs; googlevideo stream URLs
(from `bestaudio`) are plain https and also downloadable with the
captures reqwest client.
## Decisions
### D1 — Extraction engine: `yt-dlp` subprocess
Options considered:
- *(a)* **Pure-Rust extractor crates** (`rustypipe`, `rusty_ytdl`): no
external binary, but they chase YouTube's extraction changes with
small maintainer teams, and logged-in user playlists are weakly or not
supported.
- *(b)* **Invidious/Piped instances**: no extraction code at all, but a
hard runtime dependency on third-party servers of unpredictable
availability — worse than a local binary for a self-hosted player.
- *(c)* **`yt-dlp` as a subprocess** with `-J` JSON output: the de-facto
standard extractor, fastest to track YouTube changes, supports search
(`ytsearchN:`), playlists, cookies-based login, and direct stream
URLs. Cost: a non-Rust runtime dependency and subprocess plumbing.
**Decision: (c).** The binary is declared in `devenv.nix` (dev) and is a
documented runtime requirement (deploys). All calls go through one
`Engine` seam (`tokio::process::Command`, `kill_on_drop`, per-call
timeout, bounded stdout, `serde_json` parsing) so tests fake the binary
with a script and a future pure-Rust engine stays swappable.
### D2 — Crate `ytdy`, mounted at `/youtube`, non-fatal init
New workspace crate `ytdy` implementing `ProviderClient`, following
`tidaldy`'s shape. `init` probes `<binary> --version` (with timeout);
a missing or broken binary disables the provider with a warning — the
server and every other provider keep running. Settings (`ytdy.toml`):
```toml
binary = "yt-dlp" # optional; PATH lookup by default
cookies = "/path/cookies.txt" # optional; presence = "logged in"
search_results = 20 # ytsearchN cap
```
The orchestrator gains `youtube_client: Option<Arc<ytdy::Client>>` and
`/youtube` routing arms (same completeness as the other providers);
`get_lib_root` lists `youtube` only when the probe succeeded.
### D3 — Tree shape
- `/youtube` — children: `search` (always, `is_creatable`), `playlists`
(only when cookies are configured).
- `/youtube/search/<term>` — created via `%` like tidal search; terms
live in memory (`RwLock<Vec<String>>`, dedup, recreated implicitly on
stale paths), renamable and deletable. The node lists the top
`search_results` results as **tracks** (`ytsearchN:<term>`,
`--flat-playlist`); it is queueable (results are homogeneous tracks,
unlike tidal's mixed search).
- `/youtube/playlists` — the user's playlists as children
(`https://www.youtube.com/feed/playlists` with cookies, flat).
- `/youtube/playlists/<pid>` — playlist entries as tracks.
- Track paths: `<node>/<videoid>`; metadata from the flat entries
(title, uploader as artist, duration). `get_metadata_for_track` runs
a single-video `-J` when called directly.
### D4 — Streams, downloads, login
- `get_urls_for_track`: `yt-dlp -f bestaudio/best -g --no-playlist
<video>` → the stream URL(s). Cookies are passed to **every** call
when configured (search and streams benefit too, e.g. age-gated
videos).
- Downloadability mirrors tidal's central rule: a node is downloadable
when it is queueable or lists tracks; children mirror `is_queable`.
`extension_for` gains `audio/webm → webm` (bestaudio is usually opus
in webm; googlevideo URLs carry no path extension).
- "Logged in" is exactly "a cookies file is configured and readable at
init" — no OAuth flow of our own, no credential storage beyond the
user-provided file. The cookies path is config; its **contents are a
secret** and must never appear in logs or error reports.
### D5 — Subprocess discipline
- One shared `Engine` with the binary path; every invocation:
`--no-warnings -J` (or `-g`), argument-list only (never shell),
per-call timeout (default 60 s, configurable for tests), stdout
capped, stderr summarized into warnings (never echoed wholesale — it
can contain URLs).
- Errors are typed (`EngineError`: spawn/timeout/exit-status/parse) and
map to `ProviderError::FetchError`/`MalformedPath` at the trait
boundary; no panic on any subprocess condition.
- Calls run sequentially per request (no engine-level queue); the
orchestrator already spawns long walks.
### D6 — Out of scope (explicitly)
- OAuth/device login flows, cookie refresh, or storing credentials.
- Uploads, likes, subscriptions feeds, comments; YouTube Music.
- Caching search or playlist results across calls (each listing is a
fresh subprocess call, like tidal's fresh HTTP calls).
- SponsorBlock, chapters, DRM-protected content (skipped as unplayable).
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
ytdy: "ytdy (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory, like tidal)"
engine: "Engine\n(subprocess seam)"
client -> terms
client -> engine
}
bin: "yt-dlp binary\n(devenv / PATH)" {
shape: hexagon
}
yt: "YouTube" { shape: cloud }
cookies: "cookies.txt\n(optional, user-provided)" { shape: page }
server.orch -> ytdy.client: "/youtube/..."
ytdy.engine -> bin: "argv + -J, timeout,\nbounded stdout"
bin -> yt
cookies -> ytdy.engine: "--cookies (when set)"
```
## Key flow: search, then W
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
y: ytdy
e: yt-dlp
tui -> orch: "% on /youtube/search: 'lofi'"
orch -> y: "create_lib_node"
y -> e: "ytsearch20:lofi -J --flat-playlist"
e -> y: "entries (id, title, uploader, duration)"
y -> tui: "term node: 20 tracks, downloadable"
tui -> orch: "W → CaptureLibraryNode(download)"
orch -> y: "per track: get_urls_for_track"
y -> e: "-f bestaudio -g <id>"
e -> y: "googlevideo url"
orch -> orch: "captures store: stream to\n/captures/<name>/ (webm + toml)"
```
## Risks and open questions
- **`feed/playlists` extraction**: the exact yt-dlp invocation for "my
playlists" remains **live-unvalidated** (no cookies were available
during development; search, streams, and download captures were
validated live). Fallback if it turns out wrong: adjust the feed URL
in `playlists_node`, or bookmark playlist URLs as search terms.
- **yt-dlp breakage/drift**: extraction can break until the devenv pin
is bumped; errors stay typed and non-fatal.
- **Stream URL lifetime**: googlevideo URLs expire (~6 h); captures
download immediately after resolving, playback resolves on play —
both within the window.
- **Terms of service**: downloads are personal-use copies of streams the
client can already play, same stance as tidal captures.

View File

@ -1,168 +0,0 @@
# YouTube provider on rustypipe
> **Amended the same day** by D2-revised below: pure-Rust stream
> *fetching* turned out to be blocked by YouTube's PO-token
> enforcement, so stream URLs go through a minimal `yt-dlp` sidecar
> again while all metadata stays on rustypipe.
## Context and problem statement
The first `ytdy` iteration shelled out to `yt-dlp` (a Python tool). Two
problems surfaced in real use:
1. **Playback was broken.** Search worked, but picked tracks never
played: `yt-dlp -f bestaudio` selects WebM/**Opus**, and the player is
rodio + symphonia (`symphonia-all`) — symphonia has **no Opus
decoder**. The stream downloaded fine and then failed to decode.
(Download captures of YouTube tracks had the same latent problem:
`.webm` files that the local player cannot decode.)
2. The user does not want a Python subprocess in the loop.
## Evaluation of pure-Rust extractors (2026-07-21, live-tested)
- `rusty_ytdl` 0.7.4 — search works, but **stream URLs come back
empty** and `stream()` fails with "Video source empty": its cipher
handling has fallen behind YouTube's rotation (last release early
2025).
- `rustube` 0.6.0 — unmaintained since ~2022; not tested further.
- `rust-yt-downloader` 0.1.0 — a small CLI, not a library engine.
- `rustypipe` 0.11.4 — **works end to end**: search, video details,
playlists, and `player()` returns deciphered stream URLs (verified:
HTTP 206 fetch, and the full itag-140 m4a of a test video decodes
through `rodio::Decoder` — 44.1 kHz samples out). Actively maintained
(NewPipe-inspired Innertube client).
**Decision: replace the subprocess engine with `rustypipe`.** It removes
the Python dependency *and* the format problem: we pick the stream
ourselves and prefer `audio/mp4` (AAC — symphonia decodes it) over
Opus. Captures get `.m4a` audio the local player can play.
Robustness note: any non-yt-dlp extractor can break when YouTube
changes Innertube. rustypipe is the most actively maintained Rust
option, ships `rustypipe-botguard` (an optional *Rust* helper binary,
auto-detected on PATH) for PO-token attestation if YouTube starts
demanding it, and persists client state in a cache file. Accepted risk,
revisit if streams start failing.
## D1 — Extractor seam
The subprocess `Engine` is replaced by an `Extractor` trait owned by
`ytdy` (search videos, video details, audio stream URL, saved
playlists, playlist videos) with two implementations:
- `RustyPipeExtractor` — the real one, wrapping one `RustyPipe` client.
- a test fake — provider logic (path scheme, search-term store, node
shapes, gating) is tested without network or fake shell scripts.
`Client` keeps its public `ProviderClient` surface, path scheme, and
the in-memory search-term store unchanged.
```d2
direction: right
tui -> server -> ytdy: "/youtube/..."
ytdy: {
client: "Client\n(paths, search terms, nodes)"
extractor: "Extractor trait"
client -> extractor
}
ytdy.extractor -> rustypipe: "RustyPipeExtractor"
rustypipe -> youtube: "Innertube (HTTPS)"
tests -> ytdy.extractor: "FakeExtractor"
```
## D2 — Stream selection (the playback fix)
`audio_stream_url` prefers the highest-average-bitrate `audio/mp4`
stream (AAC — decodable by the player); only if none exists does it
fall back to the overall best audio stream, with a warning (it will
likely not decode locally, but the URL is still honest — e.g. a future
player may cope). Download captures inherit the same choice, so their
audio is `.m4a`.
## D2-revised — Stream *fetching* (2026-07-21, second round)
Real use immediately hit YouTube's tokenless-fetch enforcement. Live
findings against googlevideo (all reproduced, same day):
- Every rustypipe (iOS-client) stream URL serves **exactly its leading
~1 MiB**, measured to the byte (403 at cumulative 1 048 576): plain
GETs 403, open-ended ranges 403, bounded ranges work only until the
budget is spent, and a **fresh URL refuses to start at an offset**
so chaining fresh URLs per window is impossible.
- PO tokens would lift the cap, but rustypipe attaches them only to
web-family clients, whose **signature deciphering is broken upstream
right now** ("could not extract sig fn name", also on git master) —
`rustypipe-botguard` was built and tested; ineffective through the
iOS client (no `pot` parameter).
- `yt-dlp` (2026.07.04) still solves the cipher challenges; its URLs
accept plain and ranged GETs for the whole file, **throttled to
~32 KB/s** — twice the itag-140 audio bitrate, so playback holds and
captures are merely slow. Its TV/Android clients are SABR-blocked
(no URLs at all), so this is the state of the art everywhere.
**Decisions:**
1. **Windowed HTTP fetching everywhere.** The player streams through a
`WindowedHttpStream` (audio-player, a `stream-download`
`SourceStream`) and the capture `Downloader` downloads in the same
bounded ~1 MiB `Range` windows — the request pattern real players
produce, correct for every provider (servers that ignore `Range`
degrade to one 200 body; rejected windows fail typed, never loop).
2. **`yt-dlp` returns as a stream-URL-only sidecar.** All metadata
(search, playlists, details) stays on the pure-Rust rustypipe
extractor; only `get_urls_for_track` consults `yt-dlp` (argv-only,
bounded, stderr-summarized). A missing binary degrades with a
warning — streams then stop after their first ~1 MiB instead of
failing entirely.
3. **The pure-Rust path stays wired.** `botguard_bin` is configurable
(and PATH-auto-detected by rustypipe); when upstream deciphering
recovers, PO-token'd rustypipe URLs make the sidecar unnecessary
without code changes beyond removing the fallback preference.
The per-track capture deadline was raised to 30 minutes — at 32 KB/s a
long track legitimately takes that.
## D3 — Login and playlists
The `cookies` setting keeps its meaning: a path to a Netscape
`cookies.txt` export. rustypipe consumes it natively
(`user_auth_set_cookie_txt`) and — importantly — **persists the rotated
cookie in its cache file**, which outlives the (quickly stale) original
export. Init order: if the cache already holds a working login
(`user_auth_check_cookie`), use it; otherwise load the configured file;
on any failure degrade to logged-out with a warning (never a failed
init). "Logged in" gates the `playlists` subtree exactly as before,
now backed by `saved_playlists()` (the `userdata` feature) instead of a
live-unvalidated `feed/playlists` scrape.
Playlist nodes page through `Paginator::extend_limit` up to a track
cap (`MAX_PLAYLIST_TRACKS`, 1000) instead of loading whole playlists
blindly.
## D4 — Configuration and environment
`ytdy.toml`: `binary` is gone (nothing to spawn); `cookies` and
`search_results` stay; `call_timeout_secs` maps to the rustypipe
client timeout. New `RustyPipe` client state lives in
`<config>/crabidy/rustypipe/` (`storage_dir`) — it holds the rotated
auth cookie, so it is as secret as the cookies file; neither its
contents nor cookie values are ever logged. `yt-dlp` leaves
`devenv.nix`. Init never probes the network except when validating a
configured login; a failed login check degrades, a broken client build
disables the provider non-fatally (as before).
## D5 — Out of scope
- Installing `rustypipe-botguard` (optional PO-token helper); document
only. Streams work without it today.
- Opus support in the player (a symphonia Opus decoder does not exist;
an opus feature via a different rodio decoder is a separate project).
- YouTube Music (rustypipe supports it; nothing in crabidy asks yet).
## Risks
- Innertube changes can break rustypipe between releases; mitigations:
cache-backed client data, optional botguard, active upstream.
- `saved_playlists` needs valid cookies; YouTube rotates them — the
cache keeps the rotated value, but a long-cold server may need a
fresh export. Degrades to logged-out, never fails.

View File

@ -1,5 +1,5 @@
FROM ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge
RUN dpkg --add-architecture armhf
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y alsa:armhf librust-alsa-sys-dev:armhf libasound2-dev:armhf portaudio19-dev:armhf build-essential cmake libpulse-dev:armhf libdbus-1-dev:armhf pkg-config apt-utils unzip
RUN apt-get update && apt-get install -y alsa:armhf librust-alsa-sys-dev:armhf libasound2-dev:armhf portaudio19-dev:armhf build-essential libpulse-dev:armhf libdbus-1-dev:armhf pkg-config apt-utils unzip
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip

View File

@ -1,39 +1,19 @@
[package]
name = "audio-player"
version.workspace = true
edition.workspace = true
# The one axis worth a flag: Ogg-Opus decoding pulls symphonia *and*
# `symphonia-adapter-libopus`. 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-bundled"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
# Where libopus comes from (architecture/build-features.md D12). On (the
# default) compiles it from the vendored source, which needs cmake and a C
# compiler but nothing installed. Off links the system libopus instead, so a
# packaged build can use the distribution's copy: `--no-default-features
# --features opus`. The weak `?/` is load-bearing — it must not pull the
# optional adapter in by itself, or `opus-bundled` would silently re-enable
# `opus`.
opus-bundled = ["symphonia-adapter-libopus?/bundled"]
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow.workspace = true
bytes.workspace = true
flume.workspace = true
futures.workspace = true
reqwest.workspace = true
rodio.workspace = true
stream-download.workspace = true
symphonia = { workspace = true, optional = true }
symphonia-adapter-libopus = { workspace = true, optional = true }
thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing.workspace = true
url.workspace = true
rodio = { version = "0.17.1", default-features = false, features = [
"symphonia-all",
] }
symphonia = { version = "0.5.3", features = ["all"] }
stream-download = { path = "../stream-download" }
anyhow = "1.0.71"
url = "2.4.0"
flume = "0.10.14"
thiserror = "1.0.40"
tracing = "0.1.37"
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
tokio = { version = "1", features = ["full"] }

View File

@ -1,3 +1,5 @@
use std::{thread, time::Duration};
use audio_player::{Player, PlayerMessage};
#[tokio::main]
@ -11,10 +13,7 @@ async fn main() {
loop {
match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed {
duration: _,
elapsed,
}) => {
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
println!("ELAPSED: {:?}", elapsed);
}
Ok(PlayerMessage::EndOfStream) => {
@ -31,10 +30,7 @@ async fn main() {
loop {
match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed {
duration: _,
elapsed,
}) => {
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
println!("ELAPSED: {:?}", elapsed);
}
Ok(PlayerMessage::EndOfStream) => {

318
audio-player/src/decoder.rs Normal file
View File

@ -0,0 +1,318 @@
use std::error::Error;
use std::fmt;
use std::time::Duration;
use flume::Sender;
use rodio::Source;
use symphonia::{
core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{Decoder, DecoderOptions},
errors::Error as SymphoniaError,
formats::{FormatOptions, FormatReader, SeekMode, SeekTo, Track},
io::MediaSourceStream,
meta::{MetadataOptions, MetadataRevision},
probe::Hint,
units::{Time, TimeBase},
},
default::get_probe,
};
use tracing::warn;
use crate::player_engine::PlayerEngineCommand;
// Decoder errors are not considered fatal.
// The correct action is to just get a new packet and try again.
// But a decode error in more than 3 consecutive packets is fatal.
const MAX_DECODE_ERRORS: usize = 3;
#[derive(Clone)]
pub struct MediaInfo {
pub duration: Option<Duration>,
pub metadata: Option<MetadataRevision>,
pub track: Track,
}
pub struct SymphoniaDecoder {
decoder: Box<dyn Decoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
buffer: SampleBuffer<i16>,
spec: SignalSpec,
time_base: Option<TimeBase>,
duration: u64,
elapsed: u64,
metadata: Option<MetadataRevision>,
track: Track,
tx: Sender<PlayerEngineCommand>,
}
impl SymphoniaDecoder {
pub fn new(
mss: MediaSourceStream,
hint: Hint,
tx: Sender<PlayerEngineCommand>,
) -> Result<Self, DecoderError> {
match SymphoniaDecoder::init(mss, hint, tx) {
Err(e) => match e {
SymphoniaError::IoError(e) => Err(DecoderError::IoError(e.to_string())),
SymphoniaError::DecodeError(e) => Err(DecoderError::DecodeError(e)),
SymphoniaError::SeekError(_) => {
unreachable!("Seek errors should not occur during initialization")
}
SymphoniaError::Unsupported(_) => Err(DecoderError::UnrecognizedFormat),
SymphoniaError::LimitError(e) => Err(DecoderError::LimitError(e)),
SymphoniaError::ResetRequired => Err(DecoderError::ResetRequired),
},
Ok(Some(decoder)) => Ok(decoder),
Ok(None) => Err(DecoderError::NoStreams),
}
}
fn init(
mss: MediaSourceStream,
hint: Hint,
tx: Sender<PlayerEngineCommand>,
) -> symphonia::core::errors::Result<Option<SymphoniaDecoder>> {
let format_opts: FormatOptions = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts: MetadataOptions = Default::default();
let mut probed = get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;
let track = match probed.format.default_track() {
Some(stream) => stream,
None => return Ok(None),
}
.clone();
let time_base = track.codec_params.time_base;
let duration = track
.codec_params
.n_frames
.map(|frames| track.codec_params.start_ts + frames)
.unwrap_or_default();
let mut _elapsed = 0;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions { verify: true })?;
let mut decode_errors: usize = 0;
let decoded = loop {
let current_frame = probed.format.next_packet()?;
_elapsed = current_frame.ts();
match decoder.decode(&current_frame) {
Ok(decoded) => break decoded,
Err(e) => match e {
SymphoniaError::DecodeError(_) => {
decode_errors += 1;
if decode_errors > MAX_DECODE_ERRORS {
return Err(e);
} else {
continue;
}
}
_ => return Err(e),
},
}
};
let spec = decoded.spec().to_owned();
let buffer = SymphoniaDecoder::get_buffer(decoded, &spec);
// Prefer metadata that's provided in the container format, over other tags found during the
// probe operation.
let metadata = probed.format.metadata().current().cloned().or_else(|| {
probed
.metadata
.get()
.as_ref()
.and_then(|m| m.current().cloned())
});
Ok(Some(SymphoniaDecoder {
decoder,
current_frame_offset: 0,
format: probed.format,
buffer,
spec,
time_base,
duration,
elapsed: _elapsed,
metadata,
track,
tx,
}))
}
#[inline]
pub fn media_info(&self) -> MediaInfo {
MediaInfo {
duration: self.total_duration(),
metadata: self.metadata.clone(),
track: self.track.clone(),
}
}
#[inline]
pub fn elapsed(&self) -> Duration {
if let Some(tb) = self.time_base {
let time = tb.calc_time(self.elapsed);
return Duration::from_secs_f64(time.seconds as f64 + time.frac);
};
Duration::default()
}
#[inline]
pub fn seek(&mut self, time: Duration) -> Option<Duration> {
let nanos_per_sec = 1_000_000_000.0;
match self.format.seek(
SeekMode::Coarse,
SeekTo::Time {
time: Time::new(
time.as_secs(),
f64::from(time.subsec_nanos()) / nanos_per_sec,
),
track_id: None,
},
) {
Ok(seeked_to) => {
let base = TimeBase::new(1, self.sample_rate());
let time = base.calc_time(seeked_to.actual_ts);
Some(Duration::from_millis(
time.seconds * 1000 + ((time.frac * 60. * 1000.).round() as u64),
))
}
Err(_) => None,
}
}
#[inline]
fn get_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
let duration = decoded.capacity() as u64;
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
}
impl Source for SymphoniaDecoder {
#[inline]
fn current_frame_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> u16 {
self.spec.channels.count() as u16
}
#[inline]
fn sample_rate(&self) -> u32 {
self.spec.rate
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
match self.time_base {
Some(tb) => {
let time = tb.calc_time(self.duration);
Some(Duration::from_secs_f64(time.seconds as f64 + time.frac))
}
None => None,
}
}
}
impl Iterator for SymphoniaDecoder {
type Item = i16;
#[inline]
fn next(&mut self) -> Option<i16> {
if self.current_frame_offset == self.buffer.len() {
let mut decode_errors: usize = 0;
let decoded = loop {
match self.format.next_packet() {
Ok(packet) => {
self.elapsed = packet.ts();
match self.decoder.decode(&packet) {
Ok(decoded) => break decoded,
Err(e) => match e {
SymphoniaError::DecodeError(_) => {
decode_errors += 1;
if decode_errors > MAX_DECODE_ERRORS {
return None;
} else {
continue;
}
}
_ => return None,
},
}
}
Err(SymphoniaError::IoError(err)) => {
if err.kind() == std::io::ErrorKind::UnexpectedEof
&& err.to_string() == "end of stream"
{
self.tx
.send(PlayerEngineCommand::Eos)
.unwrap_or_else(|e| warn!("Send error {}", e));
return None;
}
}
Err(_) => return None,
}
};
self.spec = decoded.spec().to_owned();
self.buffer = SymphoniaDecoder::get_buffer(decoded, &self.spec);
self.current_frame_offset = 0;
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
}
/// Error that can happen when creating a decoder.
#[derive(Debug, Clone)]
pub enum DecoderError {
/// The format of the data has not been recognized.
UnrecognizedFormat,
/// An IO error occurred while reading, writing, or seeking the stream.
IoError(String),
/// The stream contained malformed data and could not be decoded or demuxed.
DecodeError(&'static str),
/// A default or user-defined limit was reached while decoding or demuxing the stream. Limits
/// are used to prevent denial-of-service attacks from malicious streams.
LimitError(&'static str),
/// The demuxer or decoder needs to be reset before continuing.
ResetRequired,
/// No streams were found by the decoder
NoStreams,
}
impl fmt::Display for DecoderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
DecoderError::UnrecognizedFormat => "Unrecognized format",
DecoderError::IoError(msg) => &msg[..],
DecoderError::DecodeError(msg) => msg,
DecoderError::LimitError(msg) => msg,
DecoderError::ResetRequired => "Reset required",
DecoderError::NoStreams => "No streams",
};
write!(f, "{}", text)
}
}
impl Error for DecoderError {}

View File

@ -1,318 +0,0 @@
//! An HLS [`SourceStream`]: fetches an `.m3u8` media playlist and streams its
//! mp3 segments in order as one continuous byte stream.
//!
//! SoundCloud (and other HLS sources) serve audio as a playlist of short mp3
//! segments rather than one file. mp3 frames byte-concatenate into a valid
//! stream (the fact `ffmpeg -c copy` relies on), so streaming the segments in
//! order yields bytes rodio's symphonia mp3 decoder handles unchanged
//! (architecture/soundcloud-provider.md D4).
//!
//! Sibling to [`crate::windowed_http::WindowedHttpStream`]. This is **forward
//! only**: it reports the source as non-seekable and length-less, so symphonia
//! does not attempt an end-seek that would need a known total length (the
//! rodio-0.22 panic the opus work documented).
//!
//! Never logs the playlist or segment URLs — they are signed and ephemeral.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures::{Future, Stream};
use stream_download::source::{DecodeError, SourceStream};
use tracing::{debug, trace, warn};
use url::Url;
/// Parameters for [`HlsStream::create`].
#[derive(Clone, Debug)]
pub struct HlsParams {
/// The `.m3u8` media-playlist URL.
pub url: Url,
pub client: reqwest::Client,
}
impl HlsParams {
pub fn new(url: Url, client: reqwest::Client) -> Self {
Self { url, client }
}
}
/// Error creating the stream (playlist fetch/parse failed).
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct HlsError(String);
impl DecodeError for HlsError {}
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
type SegmentFuture = Pin<Box<dyn Future<Output = io::Result<BytesStream>> + Send + Sync>>;
/// A parsed playlist: either the media (segment) playlist we want, or a master
/// playlist pointing at variant playlists (we follow the first).
enum Playlist {
Media(Vec<Url>),
Master(Url),
}
enum State {
/// Ready to fetch `segments[cursor]`.
Idle,
/// Waiting for a segment response.
Requesting(SegmentFuture),
/// Draining a segment body.
Streaming(BytesStream),
Finished,
}
/// See the module docs. Streams `segments[cursor]` bytes, advancing at segment
/// boundaries; finishes after the last.
pub struct HlsStream {
client: reqwest::Client,
/// Ordered mp3 segment URLs parsed from the media playlist.
segments: Vec<Url>,
/// Index of the next segment to fetch.
cursor: usize,
state: State,
}
impl std::fmt::Debug for HlsStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HlsStream")
.field("segments", &self.segments.len())
.field("cursor", &self.cursor)
.finish_non_exhaustive()
}
}
impl HlsStream {
/// Schedules the fetch of the segment at `cursor`, or finishes when none
/// remain.
fn schedule_next(&mut self) {
let Some(url) = self.segments.get(self.cursor).cloned() else {
self.state = State::Finished;
return;
};
self.cursor += 1;
self.state = State::Requesting(Box::pin(fetch_segment(self.client.clone(), url)));
}
}
/// GETs one segment, returning its body byte-stream. A non-success status is an
/// error carrying the status only (never the signed URL).
async fn fetch_segment(client: reqwest::Client, url: Url) -> io::Result<BytesStream> {
trace!("fetching hls segment");
let resp = client.get(url).send().await.map_err(|err| {
io::Error::other(format!("segment request failed: {}", err.without_url()))
})?;
if !resp.status().is_success() {
return Err(io::Error::other(format!(
"segment request rejected: {}",
resp.status()
)));
}
Ok(Box::pin(resp.bytes_stream()))
}
impl Stream for HlsStream {
type Item = io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
loop {
match &mut this.state {
State::Finished => return Poll::Ready(None),
State::Idle => this.schedule_next(),
State::Requesting(future) => match future.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(stream)) => this.state = State::Streaming(stream),
Poll::Ready(Err(err)) => {
this.state = State::Finished;
return Poll::Ready(Some(Err(err)));
}
},
State::Streaming(stream) => match stream.as_mut().poll_next(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Some(Ok(bytes))) => return Poll::Ready(Some(Ok(bytes))),
Poll::Ready(Some(Err(err))) => {
return Poll::Ready(Some(Err(io::Error::other(format!(
"segment body failed: {}",
err.without_url()
)))));
}
// Segment drained: move to the next one.
Poll::Ready(None) => this.schedule_next(),
},
}
}
}
}
impl SourceStream for HlsStream {
type Params = HlsParams;
type StreamCreationError = HlsError;
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
let segments = load_segments(&params.client, params.url).await?;
debug!(segments = segments.len(), "hls stream open");
if segments.is_empty() {
return Err(HlsError("hls playlist has no segments".to_string()));
}
Ok(Self {
client: params.client,
segments,
cursor: 0,
state: State::Idle,
})
}
/// Unknown up front (segment sizes are not in the playlist), so `None` —
/// which, with `supports_seek() == false`, keeps symphonia off the
/// end-seek path.
fn content_length(&self) -> Option<u64> {
None
}
/// Forward-only. In-track seeking (open at a segment offset) is a later,
/// additive change (architecture/soundcloud-provider.md D7).
async fn seek_range(&mut self, _start: u64, _end: Option<u64>) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"HLS stream is forward-only",
))
}
/// Best-effort resume after a dropped connection: re-open the current
/// segment. `stream-download`'s temp storage already holds everything up to
/// `current_position`, and its `Read` side serves that; this only needs to
/// resume producing fresh bytes, so re-fetching the in-flight segment is
/// acceptable (a short overlap at worst).
async fn reconnect(&mut self, _current_position: u64) -> io::Result<()> {
let restart = self.cursor.saturating_sub(1);
warn!(
segment = restart,
"reconnecting hls stream at current segment"
);
self.cursor = restart;
self.state = State::Idle;
Ok(())
}
fn supports_seek(&self) -> bool {
false
}
}
/// Fetches the playlist at `url`, following one level of master playlist, and
/// returns the ordered media-segment URLs.
async fn load_segments(client: &reqwest::Client, url: Url) -> Result<Vec<Url>, HlsError> {
let body = fetch_text(client, url.clone()).await?;
match parse_playlist(&body, &url)? {
Playlist::Media(segments) => Ok(segments),
Playlist::Master(variant) => {
let body = fetch_text(client, variant.clone()).await?;
match parse_playlist(&body, &variant)? {
Playlist::Media(segments) => Ok(segments),
// A master pointing at another master is not something
// SoundCloud produces; refuse rather than recurse.
Playlist::Master(_) => Err(HlsError("nested master playlist".to_string())),
}
}
}
}
async fn fetch_text(client: &reqwest::Client, url: Url) -> Result<String, HlsError> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| HlsError(format!("playlist request failed: {}", e.without_url())))?;
if !resp.status().is_success() {
return Err(HlsError(format!("playlist rejected: {}", resp.status())));
}
resp.text()
.await
.map_err(|e| HlsError(format!("playlist read failed: {e}")))
}
/// Parses an m3u8 body. A master playlist (`#EXT-X-STREAM-INF`) yields the
/// first variant URI; otherwise every non-comment line is a media segment.
/// Relative URIs resolve against `base`.
fn parse_playlist(body: &str, base: &Url) -> Result<Playlist, HlsError> {
let is_master = body.contains("#EXT-X-STREAM-INF");
let mut uris = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let resolved = base
.join(line)
.map_err(|e| HlsError(format!("bad segment URI: {e}")))?;
uris.push(resolved);
if is_master {
// Only the first variant is needed.
break;
}
}
if is_master {
uris.into_iter()
.next()
.map(Playlist::Master)
.ok_or_else(|| HlsError("master playlist has no variant".to_string()))
} else {
Ok(Playlist::Media(uris))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Url {
Url::parse("https://cf-hls.sndcdn.com/media/0/playlist.m3u8?token=x").unwrap()
}
#[test]
fn parses_media_playlist_absolute_and_relative() {
let body = "#EXTM3U\n\
#EXT-X-VERSION:6\n\
#EXTINF:10.0,\n\
https://cdn.sndcdn.com/media/0/1.ts\n\
#EXTINF:9.9,\n\
2.ts\n\
#EXT-X-ENDLIST\n";
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
panic!("expected media playlist");
};
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].as_str(), "https://cdn.sndcdn.com/media/0/1.ts");
// Relative URI resolved against the playlist URL.
assert_eq!(segs[1].as_str(), "https://cf-hls.sndcdn.com/media/0/2.ts");
}
#[test]
fn follows_first_master_variant() {
let body = "#EXTM3U\n\
#EXT-X-STREAM-INF:BANDWIDTH=128000\n\
variant-128.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=64000\n\
variant-64.m3u8\n";
let Playlist::Master(url) = parse_playlist(body, &base()).unwrap() else {
panic!("expected master playlist");
};
assert_eq!(
url.as_str(),
"https://cf-hls.sndcdn.com/media/0/variant-128.m3u8"
);
}
#[test]
fn ignores_comments_and_blank_lines() {
let body = "#EXTM3U\n\n#EXTINF:1,\nonly.ts\n\n";
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
panic!("expected media playlist");
};
assert_eq!(segs.len(), 1);
}
}

View File

@ -1,13 +1,7 @@
mod hls;
// Ogg-Opus needs symphonia's Ogg demuxer plus the bundled libopus decoder, so
// it is the crate's one feature (architecture/build-features.md D6).
#[cfg(feature = "opus")]
mod opus_source;
mod decoder;
mod player;
mod player_engine;
mod spectrum_tap;
pub mod windowed_http;
pub use decoder::MediaInfo;
pub use player::{Player, PlayerError};
pub use player_engine::{output_device_names, MediaInfo, PlayerMessage};
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
pub use player_engine::PlayerMessage;

View File

@ -1,342 +0,0 @@
//! Ogg-Opus decoding for the rodio pipeline.
//!
//! rodio decodes through symphonia, and symphonia 0.5 ships no Opus
//! decoder — so raw `.opus` streams (notably audiobookshelf files, but any
//! provider serving Opus) fail rodio's `Decoder::build()`. This module fills
//! that gap: it demuxes the container with symphonia's own Ogg reader and
//! decodes the Opus packets with libopus via [`symphonia_adapter_libopus`],
//! then exposes the result as a rodio [`Source`] so the rest of the player
//! (sink, spectrum tap, seeking) is unchanged.
//!
//! Only Ogg-*Opus* is handled here; every other format (including Ogg-Vorbis
//! and Ogg-FLAC, which symphonia decodes natively) stays on rodio's decoder.
//! [`is_ogg_opus`] does the sniffing and the player routes accordingly.
//!
//! The decode loop, buffering, and seek refinement mirror rodio 0.22's own
//! `SymphoniaDecoder`; the only substantive difference is that we build the
//! format reader and the codec from an explicit registry that includes the
//! libopus adapter, instead of symphonia's default `get_codecs()`.
use std::io::{Read, Result as IoResult, Seek, SeekFrom};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use symphonia::core::audio::{AudioBufferRef, SampleBuffer, SignalSpec};
use symphonia::core::codecs::{
CodecRegistry, Decoder as SymphoniaDecoderTrait, DecoderOptions, CODEC_TYPE_OPUS,
};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, SeekedTo};
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
use symphonia::core::units;
use symphonia::default::formats::OggReader;
use symphonia_adapter_libopus::OpusDecoder;
use rodio::source::SeekError;
use rodio::{ChannelCount, Sample, SampleRate, Source};
/// The Ogg page capture pattern (every page starts with it).
const OGG_MAGIC: &[u8] = b"OggS";
/// The magic signature of the Opus identification header packet, which is
/// the first packet of an Ogg-Opus stream (RFC 7845 §5.1).
const OPUS_HEAD_MAGIC: &[u8] = b"OpusHead";
/// Returns true if `header` looks like the start of an Ogg-Opus stream: an
/// Ogg page whose first packet is the Opus identification header. `header`
/// should be at least the first Ogg page (~64 bytes is plenty; `OpusHead`
/// sits at offset 28 in a well-formed stream). Ogg-Vorbis/FLAC deliberately
/// do *not* match, so they keep flowing through rodio's native decoder.
pub fn is_ogg_opus(header: &[u8]) -> bool {
header.starts_with(OGG_MAGIC) && contains(header, OPUS_HEAD_MAGIC)
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
/// A rodio [`Source`] that decodes an Ogg-Opus stream to interleaved `f32`
/// samples at libopus's native 48 kHz.
pub struct OpusSource {
decoder: Box<dyn SymphoniaDecoderTrait>,
format: Box<dyn FormatReader>,
track_id: u32,
/// Interleaved samples of the most recently decoded packet.
buffer: SampleBuffer<Sample>,
/// Read cursor into `buffer`, in samples (not frames).
span_offset: usize,
spec: SignalSpec,
total_duration: Option<Duration>,
}
impl OpusSource {
/// Builds an Opus source from a seekable byte stream. `byte_len` (the
/// content length) and `is_seekable` mirror the values the player already
/// computes for rodio; supplying them lets symphonia's Ogg reader learn
/// the final granule position (hence duration) and seek by time.
pub fn new<R>(reader: R, byte_len: Option<u64>, is_seekable: bool) -> Result<Self>
where
R: Read + Seek + Send + Sync + 'static,
{
let source = Box::new(ReadSeekSource {
inner: reader,
byte_len,
is_seekable,
});
let mss = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
let format = OggReader::try_new(mss, &FormatOptions::default())
.context("failed to open Ogg-Opus container")?;
let format: Box<dyn FormatReader> = Box::new(format);
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec == CODEC_TYPE_OPUS)
.context("Ogg stream has no Opus track")?;
let track_id = track.id;
// A registry holding only the libopus adapter — the container is
// Opus-only, so no other codec can appear.
let mut registry = CodecRegistry::new();
registry.register_all::<OpusDecoder>();
let decoder = registry
.make(&track.codec_params, &DecoderOptions::default())
.context("failed to create libopus decoder")?;
let total_duration = track
.codec_params
.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| Duration::from(base.calc_time(frames)))
.filter(|d| !d.is_zero());
// Decode the first packet up front so the struct always holds a real
// buffer and spec. symphonia's `SampleBuffer::new` divides by the
// channel count, so a zero-channel placeholder would panic; this also
// makes channel/rate queries valid before the first `next()` (rodio
// inspects the source right after construction).
let mut format = format;
let mut decoder = decoder;
let (spec, buffer) = decode_next_buffer(&mut format, &mut decoder, track_id)?
.context("Opus stream produced no audio")?;
Ok(Self {
decoder,
format,
track_id,
buffer,
span_offset: 0,
spec,
total_duration,
})
}
}
/// Decodes packets — skipping ones from other logical streams and benign
/// decode errors — until one yields audio frames, returning that frame's
/// interleaved samples and signal spec. `Ok(None)` is a clean end of stream.
fn decode_next_buffer(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn SymphoniaDecoderTrait>,
track_id: u32,
) -> Result<Option<(SignalSpec, SampleBuffer<Sample>)>> {
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
// An IO error at a page boundary is how symphonia's Ogg reader
// signals end-of-stream for a bounded file.
Err(SymphoniaError::IoError(_)) => return Ok(None),
Err(e) => return Err(anyhow!(e)).context("reading Opus packet"),
};
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) if decoded.frames() > 0 => {
let spec = *decoded.spec();
return Ok(Some((spec, copy_to_buffer(decoded, &spec))));
}
// A metadata-only packet (0 frames) or a recoverable decode error:
// skip it and keep going, matching rodio's behaviour.
Ok(_) => continue,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => return Err(anyhow!(e)).context("decoding Opus packet"),
}
}
}
/// Copies a decoded symphonia buffer into an interleaved `f32` sample buffer.
fn copy_to_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<Sample> {
let capacity = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<Sample>::new(capacity, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
impl Iterator for OpusSource {
type Item = Sample;
fn next(&mut self) -> Option<Sample> {
if self.span_offset >= self.buffer.len() {
match decode_next_buffer(&mut self.format, &mut self.decoder, self.track_id) {
Ok(Some((spec, buffer))) => {
self.spec = spec;
self.buffer = buffer;
self.span_offset = 0;
}
// Clean EOS or a fatal error both end the source.
_ => return None,
}
}
let sample = *self.buffer.samples().get(self.span_offset)?;
self.span_offset += 1;
Some(sample)
}
}
impl Source for OpusSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.len())
}
fn channels(&self) -> ChannelCount {
let count = u16::try_from(self.spec.channels.count().max(1)).unwrap_or(2);
ChannelCount::new(count).unwrap_or(ChannelCount::new(2).expect("2 is nonzero"))
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(self.spec.rate)
.unwrap_or(SampleRate::new(48_000).expect("48000 is nonzero"))
}
fn total_duration(&self) -> Option<Duration> {
self.total_duration
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
// Clamp beyond-end seeks to the end (saturating), like rodio.
let mut target = pos;
if let Some(total) = self.total_duration {
if target > total {
target = total;
}
}
// Preserve the active channel so we resume on a frame boundary.
let channels = self.channels().get() as usize;
let active_channel = self.span_offset % channels;
let seeked = self
.format
.seek(
SeekMode::Accurate,
SeekTo::Time {
time: target.into(),
track_id: Some(self.track_id),
},
)
.map_err(|e| SeekError::Other(Arc::new(e)))?;
// The demuxer moved without telling the decoder; reset it and force
// the next `next()` to refill from the new position (`span_offset`
// past the current buffer's end triggers a decode in `next()`).
self.decoder.reset();
self.span_offset = usize::MAX;
// Ogg seeks land on a page boundary before the target; fast-forward
// the residual so playback resumes at the requested instant.
self.refine_position(seeked);
// Re-align to the channel we were on before seeking.
for _ in 0..active_channel {
self.next();
}
Ok(())
}
}
impl OpusSource {
/// Skips the samples between the keyframe symphonia seeked to and the
/// exact requested timestamp. No-op when the demuxer hit the target.
fn refine_position(&mut self, seeked: SeekedTo) {
let Some(base) = self.decoder.codec_params().time_base else {
return;
};
let residual = seeked.required_ts.saturating_sub(seeked.actual_ts);
if residual == 0 {
return;
}
let seconds = Duration::from(base.calc_time(residual)).as_secs_f64();
let channels = self.channels().get() as f64;
let mut samples = (seconds * self.sample_rate().get() as f64 * channels).ceil() as usize;
samples -= samples % self.channels().get() as usize;
for _ in 0..samples {
if self.next().is_none() {
break;
}
}
}
}
/// Adapts a `Read + Seek` byte stream into symphonia's [`MediaSource`]
/// (equivalent to rodio's private `ReadSeekSource`).
struct ReadSeekSource<R> {
inner: R,
byte_len: Option<u64>,
is_seekable: bool,
}
impl<R: Read + Seek + Send + Sync> MediaSource for ReadSeekSource<R> {
fn is_seekable(&self) -> bool {
self.is_seekable
}
fn byte_len(&self) -> Option<u64> {
self.byte_len
}
}
impl<R: Read> Read for ReadSeekSource<R> {
fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
self.inner.read(buf)
}
}
impl<R: Seek> Seek for ReadSeekSource<R> {
fn seek(&mut self, pos: SeekFrom) -> IoResult<u64> {
self.inner.seek(pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_ogg_opus_header() {
// "OggS" page header with an "OpusHead" first packet.
let mut header = Vec::new();
header.extend_from_slice(b"OggS");
header.extend_from_slice(&[0u8; 24]); // rest of the 28-byte page header
header.extend_from_slice(b"OpusHead");
assert!(is_ogg_opus(&header));
}
#[test]
fn rejects_ogg_vorbis() {
let mut header = Vec::new();
header.extend_from_slice(b"OggS");
header.extend_from_slice(&[0u8; 24]);
header.extend_from_slice(b"\x01vorbis");
assert!(!is_ogg_opus(&header));
}
#[test]
fn rejects_non_ogg() {
assert!(!is_ogg_opus(b"ID3\x04OpusHead")); // mp3 with a coincidental needle
assert!(!is_ogg_opus(b"fLaC"));
assert!(!is_ogg_opus(b""));
}
}

View File

@ -3,200 +3,180 @@ use std::time::Duration;
use anyhow::Result;
use flume::{Receiver, Sender};
use tracing::error;
use tracing::{error, warn};
use std::sync::Arc;
use crate::decoder::MediaInfo;
use crate::player_engine::{PlayerEngine, PlayerEngineCommand, PlayerMessage};
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
use crate::spectrum_tap::SpectrumTap;
// TODO:
// * Emit buffering
pub enum PlayerError {}
pub struct Player {
pub messages: Receiver<PlayerMessage>,
tx_engine: Sender<PlayerEngineCommand>,
/// The spectrum tap, shared with the engine thread. The server's FFT
/// task reads it (architecture/spectrum.md).
spectrum: Arc<SpectrumTap>,
}
impl Default for Player {
fn default() -> Self {
Self::new(None)
}
}
impl Player {
/// Spawns the player engine on its own thread, sending audio to the
/// output device whose name contains `device` (case-insensitive), or the
/// system default when `device` is `None`. See
/// [`crate::output_device_names`] to discover names.
pub fn new(device: Option<String>) -> Self {
let (tx_engine, rx_engine) = flume::bounded(16);
let (tx_engine, rx_engine) = flume::bounded(10);
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
flume::bounded(16);
flume::bounded(10);
let tx_callbacks = tx_engine.clone();
// Capture the runtime handle here: the engine thread itself is not a
// tokio context but needs one to create http streams.
let runtime = tokio::runtime::Handle::try_current().ok();
let tx_decoder = tx_engine.clone();
// Created here and shared into the engine thread so callers can
// read it without reaching across the thread boundary.
let spectrum = SpectrumTap::new();
let engine_tap = spectrum.clone();
thread::spawn(move || {
let engine =
match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) {
Err(e) => {
error!("Could not initialize player: {}", e);
return;
let mut player = match PlayerEngine::init(tx_decoder, tx_player) {
Err(e) => {
error!("Could not initialize player: {}", e);
return;
}
Ok(engine) => engine,
};
loop {
match rx_engine.recv() {
Ok(PlayerEngineCommand::Play(source_str, tx)) => {
tx.send(player.play(&source_str))
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(engine) => engine,
};
engine.run(rx_engine);
Ok(PlayerEngineCommand::Pause(tx)) => {
tx.send(player.pause())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Unpause(tx)) => {
tx.send(player.unpause())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Stop(tx)) => {
tx.send(player.stop())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::TogglePlay(tx)) => {
tx.send(player.toggle_play())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Restart(tx)) => {
tx.send(player.restart())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetDuration(tx)) => {
tx.send(player.duration())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetElapsed(tx)) => {
tx.send(player.elapsed())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::SeekTo(time, tx)) => {
tx.send(player.seek_to(time))
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetVolume(tx)) => {
tx.send(player.volume())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetPaused(tx)) => {
tx.send(player.is_paused())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::SetVolume(volume, tx)) => {
tx.send(player.set_volume(volume))
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::SetElapsed(elapsed)) => {
player.handle_elapsed(elapsed);
}
Ok(PlayerEngineCommand::Eos) => {
player.handle_eos();
}
Err(e) => {
warn!("Recv error {}", e);
}
}
}
});
Self {
messages,
tx_engine,
spectrum,
}
}
}
/// The spectrum tap the played audio is mirrored into.
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
self.spectrum.clone()
}
impl Player {
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Play(source_str.to_string(), tx))
.await?;
.send(PlayerEngineCommand::Play(source_str.to_string(), tx))?;
rx.recv_async().await?
}
pub async fn restart(&self) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Restart(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Restart(tx))?;
rx.recv_async().await?
}
pub async fn elapsed(&self) -> Result<Duration> {
pub async fn elpased(&self) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetElapsed(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetElapsed(tx))?;
rx.recv_async().await?
}
pub async fn duration(&self) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetDuration(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetDuration(tx))?;
rx.recv_async().await?
}
pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::SeekTo(time, tx))
.await?;
rx.recv_async().await?
}
/// Seeks `delta_millis` from the current position — negative seeks back —
/// and resolves to the position actually reached, clamped to the track.
///
/// Relative rather than absolute on purpose: the engine adds the offset to
/// the live position, so pressing a seek key several times in a row
/// composes instead of repeatedly targeting the same place
/// (architecture/seek.md D1). Fails when nothing is loaded, and when the
/// source cannot seek at all (HLS).
pub async fn seek_by(&self, delta_millis: i64) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::SeekBy(delta_millis, tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::SeekTo(time, tx))?;
rx.recv_async().await?
}
pub async fn volume(&self) -> Result<f32> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetVolume(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetVolume(tx))?;
Ok(rx.recv_async().await?)
}
pub async fn is_paused(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetPaused(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetPaused(tx))?;
rx.recv_async().await?
}
pub async fn set_volume(&self, volume: f32) -> Result<f32> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::SetVolume(volume, tx))
.await?;
Ok(rx.recv_async().await?)
}
/// Toggles mute; resolves to the new muted state.
pub async fn toggle_mute(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::ToggleMute(tx))
.await?;
Ok(rx.recv_async().await?)
}
/// Whether output is muted, for telling a connecting client the truth
/// instead of assuming it is not.
pub async fn is_muted(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetMuted(tx))
.await?;
.send(PlayerEngineCommand::SetVolume(volume, tx))?;
Ok(rx.recv_async().await?)
}
pub async fn pause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Pause(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Pause(tx))?;
rx.recv_async().await?
}
pub async fn unpause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Unpause(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Unpause(tx))?;
rx.recv_async().await?
}
pub async fn toggle_play(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::TogglePlay(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::TogglePlay(tx))?;
rx.recv_async().await?
}
pub async fn stop(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Stop(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Stop(tx))?;
rx.recv_async().await?
}
}

View File

@ -1,80 +1,19 @@
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use flume::Sender;
use std::path::Path;
use std::sync::atomic::AtomicU64;
use std::thread;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use flume::{Receiver, RecvTimeoutError, Sender};
use rodio::source::EmptyCallback;
use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
use rodio::{Decoder, Source};
use stream_download::storage::temp::TempStorageProvider;
use stream_download::{Settings, StreamDownload};
use crate::hls::{HlsParams, HlsStream};
#[cfg(feature = "opus")]
use crate::opus_source::{is_ogg_opus, OpusSource};
use crate::spectrum_tap::{SpectrumTap, TappingSource};
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
use std::sync::Arc;
use thiserror::Error;
use tracing::{debug, info, instrument, trace, warn};
use std::{fs::File, sync::atomic::Ordering};
use symphonia::core::probe::Hint;
use tracing::{debug, warn};
use url::Url;
/// How long we wait for the initial prefetch of a network stream.
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
/// What playback logs for a source: local paths verbatim, network URLs
/// reduced to scheme and host — stream URLs embed access tokens
/// (googlevideo `sig`, tidal tokens) and must never reach the log.
fn display_source(source_str: &str) -> String {
match Url::parse(source_str) {
Ok(url) if matches!(url.scheme(), "http" | "https") => {
format!("{}://{}/…", url.scheme(), url.host_str().unwrap_or("?"))
}
_ => source_str.to_string(),
}
}
/// Interval between elapsed-position updates while playing.
const TICK_INTERVAL: Duration = Duration::from_millis(250);
/// How close to the end of a track a forward seek may land.
///
/// Seeking to the exact end is decoder-dependent — symphonia, the Opus
/// reader and a windowed http stream all disagree about whether that is an
/// error — so an overshooting forward seek stops here instead and lets the
/// track run out through the ordinary end-of-stream path, which advances the
/// queue (architecture/seek.md D4).
const SEEK_END_MARGIN: Duration = Duration::from_secs(1);
/// The absolute position a seek of `delta_millis` from `position` should land
/// on, given the track's `duration` if it is known.
///
/// Pure arithmetic, and deliberately free of assertions: it is driven
/// straight from user input, so every input — a delta larger than the track,
/// `i64::MIN`, a duration of zero, a track shorter than the step — has to
/// produce a position rather than a panic. (The previous implementation used
/// `clamp(Duration::from_secs(1), duration)`, which asserts `min <= max` and
/// therefore panicked on every duration-less stream.)
///
/// Saturates at 0 going back — a backward seek near the start means "go to
/// the beginning", not "go to the previous track". Going forward it stops
/// [`SEEK_END_MARGIN`] short of the end when the duration is known; a
/// length-less stream reports no duration, and then there is nothing to clamp
/// against, so the target passes through and the decoder decides whether it
/// can honour it.
fn seek_target(position: Duration, delta_millis: i64, duration: Option<Duration>) -> Duration {
let step = Duration::from_millis(delta_millis.unsigned_abs());
let target = if delta_millis < 0 {
position.saturating_sub(step)
} else {
position.saturating_add(step)
};
match duration {
Some(total) if !total.is_zero() => target.min(total.saturating_sub(SEEK_END_MARGIN)),
_ => target,
}
}
use crate::decoder::{MediaInfo, SymphoniaDecoder};
use anyhow::{anyhow, Result};
use rodio::{OutputStream, OutputStreamHandle, Sink, Source};
use stream_download::StreamDownload;
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
use thiserror::Error;
pub enum PlayerEngineCommand {
Play(String, Sender<Result<MediaInfo>>),
@ -87,20 +26,10 @@ pub enum PlayerEngineCommand {
GetDuration(Sender<Result<Duration>>),
GetElapsed(Sender<Result<Duration>>),
SeekTo(Duration, Sender<Result<Duration>>),
/// Seek by a signed offset in milliseconds from the live position
/// (negative seeks back). Relative rather than absolute so repeated
/// presses compose (architecture/seek.md D1).
SeekBy(i64, Sender<Result<Duration>>),
GetVolume(Sender<f32>),
ToggleMute(Sender<bool>),
/// The current muted state, so a connecting client can be told it
/// rather than assuming "not muted".
GetMuted(Sender<bool>),
GetPaused(Sender<Result<bool>>),
/// End of stream for the source started by the given generation.
/// Stale generations are ignored so an old track finishing can never
/// interfere with a newly started one.
Eos(u64),
Eos,
SetElapsed(Duration),
}
pub enum PlayerMessage {
@ -117,10 +46,8 @@ pub enum PlayerMessage {
EndOfStream,
}
#[derive(Clone, Debug)]
pub struct MediaInfo {
pub duration: Option<Duration>,
}
// TODO:
// * Emit buffering
#[derive(Debug, Error)]
pub enum PlayerEngineError {
@ -128,391 +55,85 @@ pub enum PlayerEngineError {
NotPlaying,
}
// Used for seeking in the stream
static SEEK_TO: AtomicU64 = AtomicU64::new(0);
pub struct PlayerEngine {
elapsed: Duration,
current_source: Option<String>,
media_info: Option<MediaInfo>,
/// Monotonically increasing id for the currently playing source. Used to
/// discard end-of-stream callbacks from sources that were replaced.
generation: u64,
sink: rodio::Player,
// We need to keep the device sink around; audio stops when it's dropped.
_stream: MixerDeviceSink,
sink: Sink,
// We need to keep the stream around as it will stop playing when it's dropped
_stream: OutputStream,
_handle: OutputStreamHandle,
tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>,
runtime: tokio::runtime::Handle,
// Present when the engine had to bring its own runtime because the
// creating thread was not inside a tokio context.
_owned_runtime: Option<tokio::runtime::Runtime>,
/// Shared client for windowed network streams.
http: reqwest::Client,
/// Ring the played audio is mirrored into for the spectrum
/// visualizer (architecture/spectrum.md). Handed out via
/// [`Self::spectrum_tap`] so the server's FFT task can read it.
spectrum: Arc<SpectrumTap>,
/// Whether output is muted (sink volume zeroed).
muted: bool,
/// Volume to restore on unmute.
pre_mute_volume: f32,
}
/// The names of the available audio output devices, best-effort (an empty
/// list if the host cannot be queried). Shown by `crabidy-server
/// audio-devices` so a user can pick one for the `[audio] device` config.
//
// `DeviceTrait::name` is deprecated in cpal 0.17 in favor of `description()`,
// but on the ALSA backend (the Raspberry Pi target) `name()` returns the
// stable device string users already see in `aplay -l` and match against;
// `description().name()` is newer and less proven there. Keep `name()` so the
// listing and the server's device matching use the same reliable string.
#[allow(deprecated)]
pub fn output_device_names() -> Vec<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
match rodio::cpal::default_host().output_devices() {
Ok(devices) => devices.filter_map(|device| device.name().ok()).collect(),
Err(err) => {
warn!("could not enumerate audio output devices: {err}");
Vec::new()
}
}
}
/// Opens the audio output. With `preferred` set, picks the first output
/// device whose name contains that string (case-insensitive) — so a user can
/// name a memorable fragment ("Headphones", "USB") instead of the full ALSA
/// string — and falls back to the system default with a warning if none
/// matches (the Pi's default is often HDMI, which is exactly the silent-output
/// case this selection exists to fix). With `preferred` unset, uses the
/// system default.
// `name()` deprecation: see the note on `output_device_names`.
#[allow(deprecated)]
fn open_output_device(preferred: Option<&str>) -> Result<MixerDeviceSink> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
if let Some(wanted) = preferred {
let needle = wanted.to_lowercase();
let matched = rodio::cpal::default_host()
.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|device| {
device
.name()
.map(|name| name.to_lowercase().contains(&needle))
.unwrap_or(false)
})
});
match matched {
Some(device) => {
let name = device.name().unwrap_or_else(|_| "?".to_string());
info!(device = %name, "opening selected audio output device");
return DeviceSinkBuilder::from_device(device)
.with_context(|| format!("failed to open audio device {name}"))?
.open_stream()
.with_context(|| format!("failed to open a stream on audio device {name}"));
}
None => warn!(
requested = wanted,
"no audio output device name matched; using the system default"
),
}
}
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")
}
impl PlayerEngine {
pub fn init(
tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>,
runtime: Option<tokio::runtime::Handle>,
spectrum: Arc<SpectrumTap>,
device: Option<String>,
) -> Result<Self> {
let stream = open_output_device(device.as_deref())?;
let sink = rodio::Player::connect_new(stream.mixer());
let (runtime, owned_runtime) = match runtime {
Some(handle) => (handle, None),
None => {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.context("failed to create tokio runtime for the player engine")?;
(rt.handle().clone(), Some(rt))
}
};
info!("audio output device opened");
let http = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(30))
.build()
.context("failed to build the http client")?;
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
Ok(Self {
current_source: None,
media_info: None,
generation: 0,
elapsed: Duration::default(),
sink,
_stream: stream,
_stream,
_handle: handle,
tx_engine,
tx_player,
runtime,
_owned_runtime: owned_runtime,
http,
spectrum,
muted: false,
pre_mute_volume: 1.0,
})
}
/// Drives the engine until all command senders are dropped.
pub fn run(mut self, rx_engine: Receiver<PlayerEngineCommand>) {
loop {
match rx_engine.recv_timeout(TICK_INTERVAL) {
Ok(command) => self.handle_command(command),
Err(RecvTimeoutError::Timeout) => self.tick(),
Err(RecvTimeoutError::Disconnected) => {
debug!("player engine channel closed, shutting down");
break;
}
}
}
}
fn handle_command(&mut self, command: PlayerEngineCommand) {
match command {
PlayerEngineCommand::Play(source_str, tx) => {
send_reply(tx, self.play(&source_str));
}
PlayerEngineCommand::Pause(tx) => send_reply(tx, self.pause()),
PlayerEngineCommand::Unpause(tx) => send_reply(tx, self.unpause()),
PlayerEngineCommand::Stop(tx) => send_reply(tx, self.stop()),
PlayerEngineCommand::TogglePlay(tx) => send_reply(tx, self.toggle_play()),
PlayerEngineCommand::Restart(tx) => send_reply(tx, self.restart()),
PlayerEngineCommand::GetDuration(tx) => send_reply(tx, self.duration()),
PlayerEngineCommand::GetElapsed(tx) => send_reply(tx, self.elapsed()),
PlayerEngineCommand::SeekTo(time, tx) => send_reply(tx, self.seek_to(time)),
PlayerEngineCommand::SeekBy(delta_millis, tx) => {
send_reply(tx, self.seek_by(delta_millis));
}
PlayerEngineCommand::SetVolume(volume, tx) => {
send_reply(tx, self.set_volume(volume));
}
PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()),
PlayerEngineCommand::ToggleMute(tx) => send_reply(tx, self.toggle_mute()),
PlayerEngineCommand::GetMuted(tx) => send_reply(tx, self.muted),
PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()),
PlayerEngineCommand::Eos(generation) => self.handle_eos(generation),
}
}
/// Emits an elapsed-position update while a source is playing.
fn tick(&self) {
if self.sink.empty() || self.sink.is_paused() {
return;
}
let duration = self
.media_info
.as_ref()
.and_then(|m| m.duration)
.unwrap_or_default();
// Dropping a tick when the channel is full is harmless.
let _ = self.tx_player.try_send(PlayerMessage::Elapsed {
duration,
elapsed: self.sink.get_pos(),
});
}
#[instrument(skip_all, fields(source = %display_source(source_str)))]
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
// Make-before-break: open and decode the new source *before* stopping
// the current one. Opening a network stream blocks up to
// STREAM_OPEN_TIMEOUT, and the audio sink runs on its own thread, so
// the previous track stays audible throughout — the audible gap
// shrinks to the near-instant sink swap below. If the open fails, the
// current track keeps playing and the error propagates untouched. This
// covers every transition: Replace, Next, and end-of-track re-plays.
let (source, duration) = self.open_source(source_str)?;
let tx_player = self.tx_player.clone();
let tx_engine = self.tx_engine.clone();
self.reset();
self.append_source(source);
let media_info = MediaInfo { duration };
self.media_info = Some(media_info.clone());
let (source, hint) = self.get_source(source_str)?;
let mss = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
let decoder = SymphoniaDecoder::new(mss, hint, self.tx_engine.clone())?;
let media_info = decoder.media_info();
let media_info_copy = media_info.clone();
let duration = media_info.duration.unwrap_or_default();
self.media_info = Some(media_info);
self.current_source = Some(source_str.to_string());
self.notify(PlayerMessage::Duration {
duration: duration.unwrap_or_default(),
tx_player
.send(PlayerMessage::Duration { duration })
.unwrap_or_else(|e| warn!("Send error {}", e));
// FIXME: regularly update metadata revision
let decoder = decoder.periodic_access(Duration::from_millis(250), move |src| {
let seek = SEEK_TO.load(Ordering::SeqCst);
if seek > 0 {
src.seek(Duration::from_secs(seek));
SEEK_TO.store(0, Ordering::SeqCst);
}
let elapsed = src.elapsed();
tx_engine
.send(PlayerEngineCommand::SetElapsed(elapsed))
.unwrap_or_else(|e| warn!("Send error {}", e));
tx_player
.send(PlayerMessage::Elapsed { elapsed, duration })
.unwrap_or_else(|e| warn!("Send error {}", e));
});
self.sink.append(decoder);
self.sink.play();
self.notify(PlayerMessage::Playing);
debug!(duration = ?duration, "started playback");
Ok(media_info)
}
self.tx_player
.send(PlayerMessage::Playing)
.unwrap_or_else(|e| warn!("Send error {}", e));
/// Opens and decodes a source into a ready-to-play boxed rodio source,
/// touching neither the sink nor the generation counter. This is the slow,
/// network-bound step (the initial stream prefetch); keeping it off the
/// sink lets the currently-playing track continue while it runs. Returns
/// the decoded source and its total duration if known.
fn open_source(&self, source_str: &str) -> Result<(Box<dyn Source + Send>, Option<Duration>)> {
match Url::parse(source_str) {
Ok(url) if matches!(url.scheme(), "http" | "https") => {
trace!(
host = url.host_str().unwrap_or("?"),
"opening network stream"
);
// HLS (`.m3u8`, e.g. SoundCloud) is a playlist of mp3 segments,
// streamed in order by HlsStream; every other http URL uses the
// windowed fetcher (some CDNs like googlevideo 403 plain and
// open-ended requests, serving only bounded ranges — see
// audio-player/src/windowed_http.rs).
let is_hls = Path::new(url.path())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("m3u8"));
let reader = self.runtime.block_on(async {
// Each arm normalizes its distinct `StreamInitError<S>` to
// `anyhow` so the branches unify.
let open = async {
if is_hls {
StreamDownload::new::<HlsStream>(
HlsParams::new(url.clone(), self.http.clone()),
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| anyhow!("{err}"))
} else {
StreamDownload::new::<WindowedHttpStream>(
WindowedHttpParams::new(url.clone(), self.http.clone()),
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| anyhow!("{err}"))
}
};
tokio::time::timeout(STREAM_OPEN_TIMEOUT, open)
.await
.map_err(|_| {
anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}")
})?
.context("failed to open http stream")
})?;
let byte_len = reader.content_length();
// HLS carries mp3 segments and no total length; hint mp3 and
// mark non-seekable so symphonia never end-seeks (which panics
// rodio 0.22 on a length-less stream).
let hint = if is_hls {
Some("mp3")
} else {
Path::new(url.path()).extension().and_then(|e| e.to_str())
};
self.build_source(
reader,
byte_len,
hint,
!is_hls,
"failed to decode http stream",
)
}
Ok(url) => Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
Err(_) => {
trace!(path = source_str, "opening local file");
let file = File::open(source_str)
.with_context(|| format!("failed to open file {source_str}"))?;
let byte_len = file.metadata().ok().map(|m| m.len());
let hint = Path::new(source_str)
.extension()
.and_then(|e| e.to_str())
.map(str::to_owned);
self.build_source(
BufReader::new(file),
byte_len,
hint.as_deref(),
true,
"failed to decode file",
)
}
}
}
/// Sniffs the stream's header and decodes it. Ogg-Opus — which symphonia
/// (hence rodio) cannot decode — is routed to [`OpusSource`]; every other
/// format goes through rodio's decoder. The reader is rewound after
/// sniffing so the chosen decoder sees the whole stream, and both paths
/// are wrapped in the spectrum tap (it only observes; playback is
/// unaffected).
fn build_source<R>(
&self,
mut reader: R,
byte_len: Option<u64>,
hint: Option<&str>,
seekable: bool,
decode_err: &'static str,
) -> Result<(Box<dyn Source + Send>, Option<Duration>)>
where
R: Read + Seek + Send + Sync + 'static,
{
let mut header = [0u8; 64];
let n = read_header(&mut reader, &mut header)?;
reader
.seek(SeekFrom::Start(0))
.context("failed to rewind after sniffing the header")?;
#[cfg(feature = "opus")]
if is_ogg_opus(&header[..n]) {
debug!("decoding Ogg-Opus via libopus");
let source = OpusSource::new(reader, byte_len, seekable)?;
let duration = source.total_duration();
let tapped: Box<dyn Source + Send> =
Box::new(TappingSource::new(source, self.spectrum.clone()));
return Ok((tapped, duration));
}
// Built without the `opus` feature: rodio's decoder rejects Ogg-Opus
// (symphonia has no Opus decoder), so say why rather than let a
// "malformed stream" error stand in. Playback skips the track exactly
// as it does for any undecodable file — never a panic
// (architecture/build-features.md D6).
#[cfg(not(feature = "opus"))]
if header[..n].starts_with(b"OggS") {
return Err(anyhow!(
"cannot decode Ogg-Opus: this build has no Opus decoder (rebuild with the \
`opus` feature)"
));
}
// Symphonia probes the container length during init; without a known
// byte length it seeks from the end, which rodio 0.22 turns into an
// `unreachable!` panic on a streamed source. Handing it the content
// length up front avoids that seek entirely; for length-less streams
// (HLS) `seekable` is false so symphonia never attempts that seek.
let mut builder = Decoder::builder().with_data(reader).with_seekable(seekable);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
if let Some(extension) = hint {
builder = builder.with_hint(extension);
}
let decoder = builder.build().context(decode_err)?;
let duration = decoder.total_duration();
let tapped: Box<dyn Source + Send> =
Box::new(TappingSource::new(decoder, self.spectrum.clone()));
Ok((tapped, duration))
}
/// Appends an already-decoded source to the freshly-reset sink, followed
/// by an end-of-stream callback tagged with the current generation. The
/// callback fires only when this source finishes naturally; a later
/// stop/replace bumps the generation via `reset`, so a stale source that
/// was swapped out can never signal `Next`.
fn append_source(&mut self, source: Box<dyn Source + Send>) {
self.sink.append(source);
let tx_engine = self.tx_engine.clone();
let generation = self.generation;
self.sink.append(EmptyCallback::new(Box::new(move || {
if let Err(err) = tx_engine.try_send(PlayerEngineCommand::Eos(generation)) {
warn!("failed to send end-of-stream signal: {err}");
}
})));
Ok(media_info_copy)
}
pub fn restart(&mut self) -> Result<MediaInfo> {
@ -527,7 +148,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into());
}
self.sink.pause();
self.notify(PlayerMessage::Paused);
self.tx_player
.send(PlayerMessage::Paused)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(())
}
@ -536,7 +159,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into());
}
self.sink.play();
self.notify(PlayerMessage::Playing);
self.tx_player
.send(PlayerMessage::Playing)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(())
}
@ -546,11 +171,9 @@ impl PlayerEngine {
}
if self.sink.is_paused() {
self.sink.play();
self.notify(PlayerMessage::Playing);
Ok(true)
} else {
self.sink.pause();
self.notify(PlayerMessage::Paused);
Ok(false)
}
}
@ -560,7 +183,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into());
}
self.reset();
self.notify(PlayerMessage::Stopped);
self.tx_player
.send(PlayerMessage::Stopped)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(())
}
@ -587,261 +212,77 @@ impl PlayerEngine {
if self.is_stopped() {
return Err(PlayerEngineError::NotPlaying.into());
}
Ok(self.sink.get_pos())
Ok(self.elapsed)
}
/// Seeks to an absolute position, clamped by [`seek_target`]. Returns the
/// position actually reached.
pub fn seek_to(&self, time: Duration) -> Result<Duration> {
if self.is_stopped() {
return Err(PlayerEngineError::NotPlaying.into());
}
self.seek_sink(seek_target(time, 0, self.known_duration()))
// We can seek between 1 second and the total duration of the track
let duration = self.duration().unwrap_or(self.elapsed);
let time = time.clamp(Duration::from_secs(1), duration);
SEEK_TO.store(time.as_secs(), Ordering::SeqCst);
// FIXME: ideally we would like to return once the seeking is successful
// then return the current elapsed time
// Cond-var might be needed to sleep this (seeking takes time)
Ok(time)
}
/// Seeks `delta_millis` from the **live** playback position (a negative
/// delta seeks back), clamped by [`seek_target`]. Returns the position
/// actually reached.
///
/// The offset is applied here rather than by the caller so that repeated
/// presses compose: each seek starts from where the previous one landed,
/// not from a position update that was broadcast up to a tick ago
/// (architecture/seek.md D1). Seeking with nothing loaded is an error, not
/// a way to start playback.
pub fn seek_by(&self, delta_millis: i64) -> Result<Duration> {
if self.is_stopped() {
return Err(PlayerEngineError::NotPlaying.into());
}
let target = seek_target(self.sink.get_pos(), delta_millis, self.known_duration());
self.seek_sink(target)
}
/// Seeks the sink to an already-clamped absolute position and reports the
/// position reached.
///
/// Reporting from here rather than leaving it to [`Self::tick`] is not
/// only about latency: the tick returns early for a paused sink, so
/// without this a seek while paused would leave every client showing the
/// pre-seek position until playback resumed (architecture/seek.md D6).
fn seek_sink(&self, target: Duration) -> Result<Duration> {
self.sink
.try_seek(target)
.map_err(|err| anyhow!("seek failed: {err}"))?;
let elapsed = self.sink.get_pos();
debug!(?target, ?elapsed, "seeked");
self.notify(PlayerMessage::Elapsed {
duration: self.known_duration().unwrap_or_default(),
elapsed,
});
Ok(elapsed)
}
/// The current track's total duration when the source reported one.
/// `None` covers both "no track loaded" and "a length-less stream", which
/// clamping treats alike.
fn known_duration(&self) -> Option<Duration> {
self.media_info.as_ref().and_then(|info| info.duration)
}
/// The user's intended volume — the level playback would resume at,
/// which while muted is the remembered pre-mute level rather than the
/// silenced sink volume.
pub fn volume(&self) -> f32 {
if self.muted {
self.pre_mute_volume
} else {
self.sink.volume()
}
self.sink.volume()
}
/// Sets the volume and unmutes: reaching for the volume is an intent
/// to hear something.
pub fn set_volume(&mut self, volume: f32) -> f32 {
self.muted = false;
self.sink.set_volume(volume.clamp(0.0, 1.1));
self.sink.volume()
}
/// Toggles mute by zeroing the sink volume and remembering the level
/// to restore. Returns the new muted state.
pub fn toggle_mute(&mut self) -> bool {
if self.muted {
self.sink.set_volume(self.pre_mute_volume);
self.muted = false;
} else {
self.pre_mute_volume = self.sink.volume();
self.sink.set_volume(0.0);
self.muted = true;
}
self.muted
pub fn handle_eos(&mut self) {
self.reset();
self.tx_player
.send(PlayerMessage::EndOfStream)
.unwrap_or_else(|e| warn!("Send error {}", e));
}
fn handle_eos(&mut self, generation: u64) {
if generation != self.generation {
debug!(
stale = generation,
current = self.generation,
"ignoring end-of-stream from replaced source"
);
return;
}
debug!("end of stream");
self.reset();
self.notify(PlayerMessage::EndOfStream);
pub fn handle_elapsed(&mut self, elapsed: Duration) {
self.elapsed = elapsed;
}
fn reset(&mut self) {
self.elapsed = Duration::default();
self.current_source = None;
self.media_info = None;
self.generation += 1;
self.sink.pause();
self.sink.stop();
}
fn notify(&self, message: PlayerMessage) {
self.tx_player
.send(message)
.unwrap_or_else(|e| warn!("Send error {}", e));
}
}
fn get_source(&self, source_str: &str) -> Result<(Box<dyn MediaSource>, Hint)> {
match Url::parse(source_str) {
Ok(url) => {
if let "http" | "https" = url.scheme() {
let reader = StreamDownload::new_http(source_str.parse().unwrap());
let path = Path::new(url.path());
let hint = self.get_hint(path);
fn send_reply<T>(tx: Sender<T>, value: T) {
if tx.send(value).is_err() {
warn!("player engine reply receiver dropped");
}
}
/// Reads up to `buf.len()` bytes for format sniffing, tolerating short reads
/// and `Interrupted`. Returns how many bytes were read (fewer than the buffer
/// only at end-of-stream), so a stream shorter than the buffer is not an error.
fn read_header<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e).context("failed to read stream header"),
}
}
Ok(filled)
}
#[cfg(test)]
mod tests {
use super::*;
/// A 10-minute track, the ordinary case.
fn ten_minutes() -> Option<Duration> {
Some(Duration::from_secs(600))
}
#[test]
fn seek_target_moves_by_the_delta() {
let at = Duration::from_secs(100);
assert_eq!(
seek_target(at, 15_000, ten_minutes()),
Duration::from_secs(115)
);
assert_eq!(
seek_target(at, -15_000, ten_minutes()),
Duration::from_secs(85)
);
}
/// Successive seeks compose because each one starts from the position the
/// previous one produced — the reason the delta is applied here and not by
/// the client (architecture/seek.md D1).
#[test]
fn successive_seeks_compose() {
let mut at = Duration::from_secs(100);
for _ in 0..3 {
at = seek_target(at, -15_000, ten_minutes());
}
assert_eq!(at, Duration::from_secs(55));
}
#[test]
fn seek_target_saturates_at_the_start() {
// Back past the beginning lands at 0 — not below, and not in the
// previous track.
assert_eq!(
seek_target(Duration::from_secs(3), -15_000, ten_minutes()),
Duration::ZERO
);
assert_eq!(
seek_target(Duration::ZERO, -15_000, ten_minutes()),
Duration::ZERO
);
}
/// Forward past the end stops a second short of it, so the track finishes
/// through the ordinary end-of-stream path and the queue advances.
#[test]
fn seek_target_saturates_near_the_end() {
assert_eq!(
seek_target(Duration::from_secs(595), 15_000, ten_minutes()),
Duration::from_secs(599)
);
}
/// A length-less stream reports no duration. The naive clamp turned that
/// into "seek to zero" (or panicked); the target has to pass through so
/// the decoder can decide.
#[test]
fn an_unknown_duration_does_not_clamp_forward() {
let at = Duration::from_secs(100);
assert_eq!(seek_target(at, 15_000, None), Duration::from_secs(115));
// `MediaInfo` carries `Some(0)` for some sources; same meaning.
assert_eq!(
seek_target(at, 15_000, Some(Duration::ZERO)),
Duration::from_secs(115)
);
}
/// Every extreme has to yield a position rather than a panic: the deltas
/// come from user input, and `duration` from whatever a feed claimed.
#[test]
fn seek_target_never_panics_on_extremes() {
let positions = [Duration::ZERO, Duration::from_secs(1), Duration::MAX];
let deltas = [i64::MIN, -1, 0, 1, i64::MAX];
let durations = [
None,
Some(Duration::ZERO),
// A track shorter than the end margin: the ceiling saturates to 0.
Some(Duration::from_millis(400)),
Some(Duration::from_secs(600)),
Some(Duration::MAX),
];
for position in positions {
for delta in deltas {
for duration in durations {
let _ = seek_target(position, delta, duration);
Ok((Box::new(reader), hint))
} else {
Err(anyhow!("Not a valid URL scheme: {}", url.scheme()))
}
}
Err(_) => {
let path = Path::new(source_str);
let hint = self.get_hint(path);
Ok((Box::new(File::open(path)?), hint))
}
}
// The case that used to panic outright: unknown duration, and a
// sub-second track where the old floor exceeded the old ceiling.
assert_eq!(
seek_target(Duration::ZERO, 15_000, None),
Duration::from_secs(15)
);
assert_eq!(
seek_target(Duration::ZERO, 15_000, Some(Duration::from_millis(400))),
Duration::ZERO
);
}
#[test]
fn logged_sources_never_carry_url_tokens() {
// Stream URLs embed access tokens; only scheme and host may be
// logged. Local paths pass through verbatim.
assert_eq!(
display_source("https://rr1.googlevideo.com/videoplayback?sig=SECRET&x=1"),
"https://rr1.googlevideo.com/…"
);
assert_eq!(
display_source("/home/user/music/song.m4a"),
"/home/user/music/song.m4a"
);
fn get_hint(&self, path: &Path) -> Hint {
// Create a hint to help the format registry guess what format reader is appropriate.
let mut hint = Hint::new();
// Provide the file extension as a hint.
if let Some(extension) = path.extension() {
if let Some(extension_str) = extension.to_str() {
hint.with_extension(extension_str);
}
}
hint
}
}

View File

@ -1,180 +0,0 @@
//! A near-zero-cost tap on the audio the player is playing, feeding the
//! frequency-spectrum visualizer (architecture/spectrum.md).
//!
//! [`TappingSource`] wraps the decoded rodio source and, as the mixer
//! pulls samples on the audio thread, copies each frame (downmixed to
//! mono) into a fixed lock-free ring, [`SpectrumTap`]. The server's FFT
//! task reads snapshots of that ring off the audio thread. The tap only
//! *observes*: it never blocks, allocates, or logs on the audio path,
//! and benign read/write races are acceptable for a visualizer.
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::SeekError;
use rodio::{ChannelCount, Sample, SampleRate, Source};
/// Ring length in mono frames — BeSpec's 2048-point FFT window.
pub const SPECTRUM_WINDOW: usize = 2048;
/// A fixed ring of the most recent mono samples plus a monotonically
/// increasing frame counter. Single-producer (the audio thread via
/// [`TappingSource`]) / multi-consumer (the FFT task). Samples are
/// stored as `f32` bit patterns in `AtomicU32`; races are benign.
pub struct SpectrumTap {
ring: Box<[AtomicU32]>,
/// Total frames ever written; `% SPECTRUM_WINDOW` is the next slot,
/// and the value doubles as the idle-detection counter.
written: AtomicU64,
}
impl SpectrumTap {
pub fn new() -> Arc<Self> {
let ring = (0..SPECTRUM_WINDOW)
.map(|_| AtomicU32::new(0))
.collect::<Vec<_>>()
.into_boxed_slice();
Arc::new(Self {
ring,
written: AtomicU64::new(0),
})
}
/// Audio thread: record one mono frame. One relaxed store plus a
/// counter bump — nothing else.
fn push(&self, sample: f32) {
let n = self.written.fetch_add(1, Ordering::Relaxed);
let slot = (n as usize) % SPECTRUM_WINDOW;
self.ring[slot].store(sample.to_bits(), Ordering::Relaxed);
}
/// Frames written so far. The FFT task diffs this between ticks to
/// tell "audio flowing" from "idle" without touching the player's
/// command channel (architecture/spectrum.md D2).
pub fn frame_count(&self) -> u64 {
self.written.load(Ordering::Relaxed)
}
/// A chronological snapshot (oldest first) of the ring, for the FFT.
/// May momentarily mix samples from an in-flight write — harmless
/// for visualization.
pub fn snapshot(&self) -> Vec<f32> {
let written = self.written.load(Ordering::Relaxed) as usize;
(0..SPECTRUM_WINDOW)
.map(|k| {
let idx = written.wrapping_add(k) % SPECTRUM_WINDOW;
f32::from_bits(self.ring[idx].load(Ordering::Relaxed))
})
.collect()
}
}
/// Wraps a rodio source, mirroring each played frame into a
/// [`SpectrumTap`]. Every `Source`/`Iterator` method delegates to the
/// inner source unchanged (including `try_seek`, so track seeking keeps
/// working) — the only addition is the per-frame mono downmix pushed to
/// the tap.
pub struct TappingSource<S> {
inner: S,
tap: Arc<SpectrumTap>,
/// Channels of the current span; re-read at each frame boundary so a
/// mid-stream channel change cannot desync the downmix.
channels: u16,
channel_index: u16,
frame_sum: f32,
}
impl<S: Source> TappingSource<S> {
pub fn new(inner: S, tap: Arc<SpectrumTap>) -> Self {
let channels = inner.channels().get();
Self {
inner,
tap,
channels,
channel_index: 0,
frame_sum: 0.0,
}
}
}
impl<S: Source> Iterator for TappingSource<S> {
type Item = Sample;
fn next(&mut self) -> Option<Sample> {
let sample = self.inner.next()?;
self.frame_sum += sample;
self.channel_index += 1;
if self.channel_index >= self.channels {
let mono = self.frame_sum / f32::from(self.channels.max(1));
self.tap.push(mono);
self.frame_sum = 0.0;
self.channel_index = 0;
// Track channel-count changes between spans.
self.channels = self.inner.channels().get();
}
Some(sample)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl<S: Source> Source for TappingSource<S> {
fn current_span_len(&self) -> Option<usize> {
self.inner.current_span_len()
}
fn channels(&self) -> ChannelCount {
self.inner.channels()
}
fn sample_rate(&self) -> SampleRate {
self.inner.sample_rate()
}
fn total_duration(&self) -> Option<Duration> {
self.inner.total_duration()
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
self.inner.try_seek(pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rodio::buffer::SamplesBuffer;
#[test]
fn snapshot_returns_the_window_oldest_first() {
let tap = SpectrumTap::new();
// Write one full window plus a bit, so the ring has wrapped.
for i in 0..(SPECTRUM_WINDOW + 3) {
tap.push(i as f32);
}
let snap = tap.snapshot();
assert_eq!(snap.len(), SPECTRUM_WINDOW);
// Oldest retained frame is (total - window); newest is total-1.
let total = (SPECTRUM_WINDOW + 3) as f32;
assert_eq!(*snap.first().unwrap(), total - SPECTRUM_WINDOW as f32);
assert_eq!(*snap.last().unwrap(), total - 1.0);
}
#[test]
fn tapping_source_downmixes_and_passes_samples_through() {
// Stereo: [L,R, L,R] = frames (1,3) and (5,7) → mono 2 and 6.
let tap = SpectrumTap::new();
let buf = SamplesBuffer::new(
ChannelCount::new(2).unwrap(),
SampleRate::new(44_100).unwrap(),
vec![1.0f32, 3.0, 5.0, 7.0],
);
let tapped: Vec<f32> = TappingSource::new(buf, tap.clone()).collect();
// Playback is untouched: every sample passes through verbatim.
assert_eq!(tapped, vec![1.0, 3.0, 5.0, 7.0]);
// Two mono frames were tapped.
assert_eq!(tap.frame_count(), 2);
let snap = tap.snapshot();
assert_eq!(snap[SPECTRUM_WINDOW - 2], 2.0);
assert_eq!(snap[SPECTRUM_WINDOW - 1], 6.0);
}
}

View File

@ -1,547 +0,0 @@
//! A windowed HTTP [`SourceStream`]: fetches media in small **bounded**
//! `Range` requests instead of one open-ended GET.
//!
//! Some CDNs (notably googlevideo, see
//! `architecture/youtube-rustypipe.md`) reject plain and open-ended
//! requests from unattested clients with `403 Forbidden` and only serve
//! bounded ranges of about a megabyte — the request pattern real players
//! produce. This stream chains such windows transparently; servers that
//! ignore the `Range` header (plain `200`) degrade to one continuous
//! body without windowing (and without seek support).
//!
//! Error messages never include the URL — stream URLs may embed tokens.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures::{Future, Stream};
use stream_download::source::{DecodeError, SourceStream};
use tracing::{debug, trace, warn};
use url::Url;
/// Bytes per request window. Verified against googlevideo: 1 MiB windows
/// are served, 8 MiB and open-ended requests are rejected.
pub const WINDOW_SIZE: u64 = 1024 * 1024;
/// Parameters for [`WindowedHttpStream::create`].
#[derive(Clone, Debug)]
pub struct WindowedHttpParams {
pub url: Url,
pub client: reqwest::Client,
/// Window size in bytes; [`WINDOW_SIZE`] outside of tests.
pub window: u64,
}
impl WindowedHttpParams {
pub fn new(url: Url, client: reqwest::Client) -> Self {
Self {
url,
client,
window: WINDOW_SIZE,
}
}
}
/// Error creating the stream (first window request failed).
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct WindowedHttpError(String);
impl DecodeError for WindowedHttpError {}
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
type WindowFuture = Pin<Box<dyn Future<Output = io::Result<Window>> + Send + Sync>>;
/// One server response being drained.
struct Window {
stream: BytesStream,
/// Absolute end (exclusive) of the bytes this response carries;
/// `u64::MAX` for an un-ranged whole-body response.
end: u64,
/// Total resource size, when the response revealed it.
total: Option<u64>,
/// The server honored the `Range` header (`206`).
ranged: bool,
}
enum State {
/// Draining the current response.
Streaming {
stream: BytesStream,
end: u64,
},
/// Waiting for the next window request.
Requesting(WindowFuture),
Finished,
}
/// See the module docs.
pub struct WindowedHttpStream {
// (Debug impl below — the state machine holds unnameable futures.)
client: reqwest::Client,
url: Url,
window: u64,
/// Total resource size (from `Content-Range`); `None` when unknown.
content_length: Option<u64>,
/// The server honors ranges — windowing and seeking are available.
ranged: bool,
/// Absolute offset of the next byte to hand out.
position: u64,
/// Exclusive end requested via `seek_range`; `None` = to the end.
limit: Option<u64>,
/// Bytes to silently drop before yielding (un-ranged reconnect
/// catch-up).
discard: u64,
state: State,
}
/// Requests one bounded window `[start, end_exclusive)`.
///
/// `206` yields a ranged window (end and total parsed from
/// `Content-Range`, falling back to `Content-Length`); `200` means the
/// server ignored the header and sent the whole body; `416` past the end
/// yields an empty terminal window. Anything else is an error carrying
/// the status only.
async fn request_window(
client: reqwest::Client,
url: Url,
start: u64,
end_exclusive: u64,
) -> io::Result<Window> {
let range = format!("bytes={start}-{}", end_exclusive.saturating_sub(1));
trace!(range, "requesting window");
let response = client
.get(url)
.header(reqwest::header::RANGE, range)
.send()
.await
.map_err(|err| io::Error::other(format!("window request failed: {}", err.without_url())))?;
match response.status() {
reqwest::StatusCode::PARTIAL_CONTENT => {
// `Content-Range: bytes <start>-<end>/<total|*>`
let content_range = response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let (end, total) = match content_range.as_deref().and_then(parse_content_range) {
Some((_, range_end, total)) => (range_end + 1, total),
None => {
// No usable Content-Range: derive the window end from
// the body length.
let len = response.content_length().unwrap_or(0);
(start + len, None)
}
};
Ok(Window {
stream: Box::pin(response.bytes_stream()),
end,
total,
ranged: true,
})
}
reqwest::StatusCode::OK => {
let total = response.content_length();
Ok(Window {
stream: Box::pin(response.bytes_stream()),
end: u64::MAX,
total,
ranged: false,
})
}
reqwest::StatusCode::RANGE_NOT_SATISFIABLE => Ok(Window {
stream: Box::pin(futures::stream::empty()),
end: start,
total: None,
ranged: true,
}),
status => Err(io::Error::other(format!(
"window request rejected: {status}"
))),
}
}
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
let rest = value.trim().strip_prefix("bytes ")?;
let (range, total) = rest.split_once('/')?;
let (start, end) = range.split_once('-')?;
let total = match total.trim() {
"*" => None,
n => Some(n.parse().ok()?),
};
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
}
impl std::fmt::Debug for WindowedHttpStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WindowedHttpStream")
.field("content_length", &self.content_length)
.field("ranged", &self.ranged)
.field("position", &self.position)
.finish_non_exhaustive()
}
}
impl WindowedHttpStream {
/// The exclusive end the consumer currently wants: the seek limit
/// clamped to the known size.
fn effective_end(&self) -> Option<u64> {
match (self.limit, self.content_length) {
(Some(limit), Some(len)) => Some(limit.min(len)),
(Some(limit), None) => Some(limit),
(None, len) => len,
}
}
/// Schedules the request for the window starting at `start`, or
/// finishes when nothing is left to fetch.
fn schedule_window(&mut self, start: u64) {
let end = match self.window_end(start) {
Some(end) => end,
None => {
self.state = State::Finished;
return;
}
};
self.state = State::Requesting(Box::pin(request_window(
self.client.clone(),
self.url.clone(),
start,
end,
)));
}
/// The exclusive end of the window starting at `start`; `None` when
/// nothing is left to fetch.
fn window_end(&self, start: u64) -> Option<u64> {
match self.effective_end() {
Some(effective) if start >= effective => None,
Some(effective) => Some((start + self.window).min(effective)),
None => Some(start + self.window),
}
}
/// Eagerly opens the window at `start` (seeks and reconnects), so
/// request failures surface to the caller instead of re-arising on
/// every poll.
async fn open_window(&mut self, start: u64) -> io::Result<()> {
match self.window_end(start) {
None => {
self.state = State::Finished;
Ok(())
}
Some(end) => {
let window =
request_window(self.client.clone(), self.url.clone(), start, end).await?;
self.state = State::Streaming {
stream: window.stream,
end: window.end,
};
Ok(())
}
}
}
}
impl Stream for WindowedHttpStream {
type Item = io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
loop {
match &mut this.state {
State::Finished => return Poll::Ready(None),
State::Requesting(future) => match future.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(err)) => {
this.state = State::Finished;
return Poll::Ready(Some(Err(err)));
}
Poll::Ready(Ok(window)) => {
this.state = State::Streaming {
stream: window.stream,
end: window.end,
};
}
},
State::Streaming { stream, end } => match stream.as_mut().poll_next(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Some(Err(err))) => {
return Poll::Ready(Some(Err(io::Error::other(format!(
"stream body failed: {}",
err.without_url()
)))));
}
Poll::Ready(Some(Ok(mut bytes))) => {
// Un-ranged reconnect catch-up: drop the prefix the
// consumer already has.
if this.discard > 0 {
let drop_now = this.discard.min(bytes.len() as u64) as usize;
let _ = bytes.split_to(drop_now);
this.discard -= drop_now as u64;
if bytes.is_empty() {
continue;
}
}
this.position += bytes.len() as u64;
return Poll::Ready(Some(Ok(bytes)));
}
Poll::Ready(None) => {
let window_end = *end;
if !this.ranged {
// One whole-body response: its end is the end.
this.state = State::Finished;
return Poll::Ready(None);
}
if let Some(effective) = this.effective_end() {
if this.position >= effective {
this.state = State::Finished;
return Poll::Ready(None);
}
}
if this.position < window_end && this.content_length.is_none() {
// A short window with no known total: the
// resource ended early.
this.state = State::Finished;
return Poll::Ready(None);
}
let next = this.position;
this.schedule_window(next);
}
},
}
}
}
}
impl SourceStream for WindowedHttpStream {
type Params = WindowedHttpParams;
type StreamCreationError = WindowedHttpError;
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
let window = request_window(params.client.clone(), params.url.clone(), 0, params.window)
.await
.map_err(|err| WindowedHttpError(err.to_string()))?;
let content_length = window.total;
let ranged = window.ranged;
debug!(?content_length, ranged, "windowed http stream open");
Ok(Self {
client: params.client,
url: params.url,
window: params.window,
content_length,
ranged,
position: 0,
limit: None,
discard: 0,
state: State::Streaming {
stream: window.stream,
end: window.end,
},
})
}
fn content_length(&self) -> Option<u64> {
self.content_length
}
async fn seek_range(&mut self, start: u64, end: Option<u64>) -> io::Result<()> {
trace!(start, ?end, "seek");
self.position = start;
self.limit = end;
self.discard = 0;
// Eager: the request happens *here*, so a rejected window is an
// error the retry logic can time out on — a lazily scheduled
// request that keeps failing would look like a successful
// reconnect every time and retry forever.
self.open_window(start).await
}
async fn reconnect(&mut self, current_position: u64) -> io::Result<()> {
if self.ranged {
self.position = current_position;
self.discard = 0;
return self.open_window(current_position).await;
}
// The server does not honor ranges: refetch from the start and
// drop what the consumer already has.
warn!(
current_position,
"reconnecting to a server without range support"
);
self.position = current_position;
self.discard = current_position;
let window = request_window(self.client.clone(), self.url.clone(), 0, u64::MAX).await?;
self.state = State::Streaming {
stream: window.stream,
end: window.end,
};
Ok(())
}
fn supports_seek(&self) -> bool {
self.ranged
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A minimal HTTP server for one fixed body. With `ranged`, bounded
/// `Range` requests get `206` + `Content-Range` slices — and, like
/// googlevideo, open-ended or oversized ranges get `403`. Without,
/// every request gets the whole body as `200`.
async fn serve(body: Vec<u8>, ranged: bool, max_window: u64) -> Url {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let request = String::from_utf8_lossy(&buf).to_lowercase();
let range = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.and_then(|spec| {
let (start, end) = spec.trim().split_once('-')?;
let start: u64 = start.parse().ok()?;
let end: Option<u64> = end.parse().ok();
Some((start, end))
});
let total = body.len() as u64;
let response = match (ranged, range) {
(true, Some((start, Some(end))))
if start < total && end - start < max_window =>
{
let end = end.min(total - 1);
let slice = &body[start as usize..=end as usize];
let mut head = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {start}-{end}/{total}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
slice.len()
)
.into_bytes();
head.extend_from_slice(slice);
head
}
(true, Some((start, _))) if start >= total => {
format!("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{total}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.into_bytes()
}
(true, _) => {
// Open-ended or oversized: rejected, like
// googlevideo without a PO token.
b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
}
(false, _) => {
let mut head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n"
)
.into_bytes();
head.extend_from_slice(&body);
head
}
};
let _ = sock.write_all(&response).await;
let _ = sock.shutdown().await;
});
}
});
Url::parse(&format!("http://{addr}/stream")).expect("url")
}
fn params(url: Url, window: u64) -> WindowedHttpParams {
WindowedHttpParams {
url,
client: reqwest::Client::new(),
window,
}
}
async fn read_all(stream: &mut WindowedHttpStream) -> Vec<u8> {
let mut out = Vec::new();
while let Some(chunk) = stream.next().await {
out.extend_from_slice(&chunk.expect("chunk"));
}
out
}
#[tokio::test]
async fn chains_windows_over_a_range_only_server() {
// 10 windows of 16 bytes against a server that 403s anything
// bigger — exactly the googlevideo behavior.
let body: Vec<u8> = (0..160u32).map(|i| i as u8).collect();
let url = serve(body.clone(), true, 64).await;
let mut stream = WindowedHttpStream::create(params(url, 16))
.await
.expect("create");
assert_eq!(stream.content_length(), Some(160));
assert!(stream.supports_seek());
assert_eq!(read_all(&mut stream).await, body);
}
#[tokio::test]
async fn seeks_restart_the_window_chain() {
let body: Vec<u8> = (0..160u32).map(|i| i as u8).collect();
let url = serve(body.clone(), true, 64).await;
let mut stream = WindowedHttpStream::create(params(url, 16))
.await
.expect("create");
stream.seek_range(100, None).await.expect("seek");
assert_eq!(read_all(&mut stream).await, body[100..]);
}
#[tokio::test]
async fn plain_servers_stream_one_body_without_seek() {
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
let url = serve(body.clone(), false, 0).await;
let mut stream = WindowedHttpStream::create(params(url, 16))
.await
.expect("create");
assert_eq!(stream.content_length(), Some(100));
assert!(!stream.supports_seek());
assert_eq!(read_all(&mut stream).await, body);
}
#[tokio::test]
async fn rejections_surface_as_errors_without_the_url() {
// A ranged server with max_window 0 rejects everything.
let url = serve(vec![0; 10], true, 0).await;
let err = WindowedHttpStream::create(params(url, 16))
.await
.expect_err("403 fails creation");
let message = err.to_string();
assert!(message.contains("403"), "{message}");
assert!(!message.contains("127.0.0.1"), "no url: {message}");
}
#[test]
fn content_range_parses_totals_and_wildcards() {
assert_eq!(
parse_content_range("bytes 0-1023/7831134"),
Some((0, 1023, Some(7831134)))
);
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, 9, None)));
assert_eq!(parse_content_range("garbage"), None);
}
}

View File

@ -1,28 +0,0 @@
[package]
name = "cbd-cli"
version.workspace = true
edition.workspace = true
[dependencies]
clap.workspace = true
clap_complete.workspace = true
clap_mangen.workspace = true
# The `client` feature adds the gRPC executor for the remote
# library/queue/global commands. Kept optional so binaries' build.rs can
# depend on this crate (default features = clap only) without dragging in
# tonic on every build (architecture/cli.md D1/D7).
crabidy-core = { workspace = true, optional = true }
tonic = { workspace = true, optional = true, features = [
"transport",
"codegen",
] }
tokio = { workspace = true, optional = true, features = [
"rt-multi-thread",
"macros",
] }
base64 = { workspace = true, optional = true }
[features]
default = []
client = ["dep:crabidy-core", "dep:tonic", "dep:tokio", "dep:base64"]

View File

@ -1,435 +0,0 @@
//! The gRPC executor for the remote `library`/`queue`/`global` commands
//! (`client` feature). Connects a generated `crabidy-core` client with a
//! basic-auth interceptor and runs one command against a server, printing a
//! human-readable result.
//!
//! Transport and gRPC `Status` errors are mapped to a short one-line message
//! (no color-eyre chain dump) — an ordinary "server unreachable" reads as a
//! single line (architecture/cli.md D3, quality gate "remote errors").
use std::time::Duration;
use base64::Engine;
use tonic::metadata::MetadataValue;
use tonic::service::{interceptor::InterceptedService, Interceptor};
use tonic::transport::{Channel, Endpoint};
use tonic::{Request, Status};
use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
DeleteLibraryNodeRequest, GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode,
NextRequest, PrevRequest, Queue, QueueSort, RemoveRequest, RenameLibraryNodeRequest,
ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest,
SortQueueRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
ToggleShuffleRequest, Track,
};
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd, SortKey};
/// The reserved library path the live queue mirrors into; `queue capture`
/// captures it (architecture/crabidy-store.md).
const CURRENT_NODE: &str = "/crabidy/current";
/// How long to wait for the TCP connect before giving up — a CLI must not
/// hang forever against an unreachable server.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Per-request deadline. Generous: `CaptureLibraryNode` returns once the
/// capture is *accepted*; the walk runs server-side.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Attaches `authorization: Basic …` when a user is configured; an empty user
/// means an open server. The header value is a secret and never logged.
#[derive(Clone)]
pub struct AuthInterceptor {
header: Option<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
fn new(user: &str, password: &str) -> Result<Self, Box<dyn std::error::Error>> {
if user.is_empty() {
return Ok(Self { header: None });
}
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
let header = format!("Basic {encoded}")
.parse()
.map_err(|_| "cannot encode credentials header")?;
Ok(Self {
header: Some(header),
})
}
}
impl Interceptor for AuthInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
/// Connects (lazily) to the server described by `conn`, with a bounded
/// connect timeout and per-request deadline.
async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>> {
let endpoint = Endpoint::from_shared(conn.address.clone())?
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.connect_lazy();
let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?;
Ok(CrabidyServiceClient::with_interceptor(
endpoint,
interceptor,
))
}
/// Maps a gRPC [`Status`] to a short, single-line error — the code plus its
/// message, never an internal report chain. A transport failure surfaces as
/// `Unavailable` with tonic's one-line reason.
fn rpc_error(status: Status) -> Box<dyn std::error::Error> {
let code = status.code();
let message = status.message();
if message.is_empty() {
format!("server error: {code}").into()
} else {
format!("{message} ({code})").into()
}
}
/// Runs one remote command against the server and prints the result.
pub async fn run_remote(
conn: &Connection,
cmd: RemoteCmd,
) -> Result<(), Box<dyn std::error::Error>> {
let mut client = connect(conn).await?;
match cmd {
RemoteCmd::Library(cmd) => run_library(&mut client, cmd).await,
RemoteCmd::Queue(cmd) => run_queue(&mut client, cmd).await,
RemoteCmd::Global(cmd) => run_global(&mut client, cmd).await,
}
}
async fn run_library(
client: &mut Client,
cmd: LibraryCmd,
) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
LibraryCmd::List { path } => {
let response = client
.get_library_node(GetLibraryNodeRequest { path: path.clone() })
.await
.map_err(rpc_error)?;
match response.into_inner().node {
Some(node) => print_node(&node),
None => return Err(format!("no such library node: {path}").into()),
}
}
LibraryCmd::Create { parent, title } => {
client
.create_library_node(CreateLibraryNodeRequest {
parent_path: parent.clone(),
title: title.clone(),
})
.await
.map_err(rpc_error)?;
println!("created \"{title}\" under {parent}");
}
LibraryCmd::Rename { path, title } => {
client
.rename_library_node(RenameLibraryNodeRequest {
path: path.clone(),
new_title: title.clone(),
})
.await
.map_err(rpc_error)?;
println!("renamed {path} to \"{title}\"");
}
LibraryCmd::Delete { path } => {
client
.delete_library_node(DeleteLibraryNodeRequest { path: path.clone() })
.await
.map_err(rpc_error)?;
println!("deleted {path}");
}
LibraryCmd::Save { path, name } => {
capture(client, &path, &name, false).await?;
println!("saving {path} as /crabidy/{name} (link)");
}
LibraryCmd::Capture { path, name } => {
capture(client, &path, &name, true).await?;
println!("capturing {path} into /crabidy/{name} (download)");
}
}
Ok(())
}
/// The wire strategy a CLI `sort <key>` names (architecture/queue-order.md D17).
fn wire_sort(key: SortKey) -> QueueSort {
match key {
SortKey::Artist => QueueSort::Artist,
SortKey::Album => QueueSort::Album,
SortKey::Title => QueueSort::Title,
SortKey::Duration => QueueSort::Duration,
SortKey::Reverse => QueueSort::Reverse,
}
}
/// The CLI spelling of a strategy, so the confirmation line names it the way
/// the user typed it.
fn sort_label(key: SortKey) -> &'static str {
match key {
SortKey::Artist => "artist",
SortKey::Album => "album",
SortKey::Title => "title",
SortKey::Duration => "duration",
SortKey::Reverse => "reverse",
}
}
async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
QueueCmd::Show => {
let response = client.init(InitRequest {}).await.map_err(rpc_error)?;
match response.into_inner().queue {
Some(queue) => print_queue(&queue),
None => println!("the queue is empty"),
}
}
QueueCmd::Append { paths } => {
let n = paths.len();
client
.append(AppendRequest { paths })
.await
.map_err(rpc_error)?;
println!("appended {n} path(s)");
}
QueueCmd::Insert { position, paths } => {
let n = paths.len();
client
.insert(InsertRequest { position, paths })
.await
.map_err(rpc_error)?;
println!("inserted {n} path(s) at {position}");
}
QueueCmd::Replace { paths } => {
let n = paths.len();
client
.replace(ReplaceRequest { paths })
.await
.map_err(rpc_error)?;
println!("replaced the queue with {n} path(s)");
}
QueueCmd::Remove { positions } => {
let n = positions.len();
client
.remove(RemoveRequest { positions })
.await
.map_err(rpc_error)?;
println!("removed {n} entry(ies)");
}
QueueCmd::Clear { keep_current } => {
client
.clear_queue(ClearQueueRequest {
exclude_current: keep_current,
})
.await
.map_err(rpc_error)?;
println!("cleared the queue");
}
QueueCmd::Dedup { titles } => {
let response = client
.dedup_queue(DedupQueueRequest { by_title: titles })
.await
.map_err(rpc_error)?;
// 0 is a real answer — "there were no duplicates" — so it is
// printed like any other count (architecture/queue-order.md D2).
let removed = response.into_inner().removed;
if titles {
println!("removed {removed} same-title duplicate(s)");
} else {
println!("removed {removed} duplicate(s)");
}
}
QueueCmd::Sort { key, desc } => {
client
.sort_queue(SortQueueRequest {
sort: wire_sort(key) as i32,
descending: desc,
})
.await
.map_err(rpc_error)?;
match key {
SortKey::Reverse => println!("reversed the queue"),
_ if desc => println!("sorted the queue by {} (descending)", sort_label(key)),
_ => println!("sorted the queue by {}", sort_label(key)),
}
}
QueueCmd::SetCurrent { position } => {
client
.set_current(SetCurrentRequest { position })
.await
.map_err(rpc_error)?;
println!("jumped to position {position}");
}
QueueCmd::Save { name } => {
client
.save_queue(SaveQueueRequest { name: name.clone() })
.await
.map_err(rpc_error)?;
println!("saving the queue as /crabidy/{name} (link)");
}
QueueCmd::Capture { name } => {
capture(client, CURRENT_NODE, &name, true).await?;
println!("capturing the queue into /crabidy/{name} (download)");
}
QueueCmd::Shuffle => {
client
.toggle_shuffle(ToggleShuffleRequest {})
.await
.map_err(rpc_error)?;
println!("toggled shuffle");
}
QueueCmd::Repeat => {
client
.toggle_repeat(ToggleRepeatRequest {})
.await
.map_err(rpc_error)?;
println!("toggled repeat");
}
}
Ok(())
}
async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
GlobalCmd::Play => {
client
.toggle_play(TogglePlayRequest {})
.await
.map_err(rpc_error)?;
println!("toggled play/pause");
}
GlobalCmd::Stop => {
client.stop(StopRequest {}).await.map_err(rpc_error)?;
println!("stopped");
}
GlobalCmd::Next => {
client.next(NextRequest {}).await.map_err(rpc_error)?;
println!("next track");
}
GlobalCmd::Prev => {
client.prev(PrevRequest {}).await.map_err(rpc_error)?;
println!("previous track");
}
GlobalCmd::Restart => {
client
.restart_track(RestartTrackRequest {})
.await
.map_err(rpc_error)?;
println!("restarted the current track");
}
GlobalCmd::Seek { seconds } => {
// Milliseconds on the wire; the server clamps to the track.
client
.seek(SeekRequest {
delta_millis: seconds.saturating_mul(1_000),
})
.await
.map_err(rpc_error)?;
println!("seeked {seconds:+} seconds");
}
GlobalCmd::Mute => {
client
.toggle_mute(ToggleMuteRequest {})
.await
.map_err(rpc_error)?;
println!("toggled mute");
}
GlobalCmd::Volume { delta } => {
client
.change_volume(ChangeVolumeRequest { delta })
.await
.map_err(rpc_error)?;
println!("changed volume by {delta:+}");
}
}
Ok(())
}
/// Issues a `CaptureLibraryNode` (link save or download capture).
async fn capture(
client: &mut Client,
path: &str,
name: &str,
download: bool,
) -> Result<(), Box<dyn std::error::Error>> {
client
.capture_library_node(CaptureLibraryNodeRequest {
path: path.to_string(),
name: name.to_string(),
download,
})
.await
.map_err(rpc_error)?;
Ok(())
}
/// Prints a library node: its path/title, child nodes, then tracks. Captured
/// rows are marked `*` (architecture/crabidy-store.md).
fn print_node(node: &LibraryNode) {
let marker = if node.is_captured { " *" } else { "" };
println!("{} \"{}\"{marker}", node.path, node.title);
if !node.children.is_empty() {
println!("nodes:");
for child in &node.children {
let marker = if child.is_captured { " *" } else { "" };
println!(" {} \"{}\"{marker}", child.path, child.title);
}
}
if !node.tracks.is_empty() {
println!("tracks:");
for track in &node.tracks {
println!(" {} {}", track.path, track_label(track));
}
}
if node.children.is_empty() && node.tracks.is_empty() {
println!("(empty)");
}
}
/// Prints the current queue with a marker on the current track.
fn print_queue(queue: &Queue) {
if queue.tracks.is_empty() {
println!("the queue is empty");
return;
}
for (index, track) in queue.tracks.iter().enumerate() {
let here = if index as u32 == queue.current_position {
">"
} else {
" "
};
println!("{here} {index:>3} {}", track_label(track));
}
}
/// A one-line `artist - title` label, marking captured/skipped tracks.
fn track_label(track: &Track) -> String {
let mut label = if track.artist.is_empty() {
track.title.clone()
} else {
format!("{} - {}", track.artist, track.title)
};
if track.is_captured {
label.push_str(" *");
}
if track.is_skipped {
label.push_str(" (skipped)");
}
label
}

View File

@ -1,563 +0,0 @@
//! Shared command-line definitions for the crabidy binaries
//! (`crabidy-server`, `cbd-tui`, `cbd`), plus a feature-gated gRPC executor
//! for the remote `library`/`queue`/`global` commands.
//!
//! See `architecture/cli.md`. The clap types live here so every binary shares
//! one command surface and each binary's `build.rs` can generate shell
//! completions and man pages from its own top-level [`clap::Command`] with the
//! default (clap-only) feature set. The `client` feature adds [`run_remote`],
//! which the binaries call to execute a remote command against a running
//! server.
use clap::{Args, Parser, Subcommand, ValueEnum};
/// A server auth role. The `ValueEnum` names double as the basic-auth user
/// names the server expects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Role {
/// Full control (the normal user).
Owner,
/// Anything on the queue and playback, no library writes.
QueueOwner,
/// May browse and append to the queue; nothing else.
Appender,
}
impl Role {
/// The basic-auth user name for this role.
pub fn user_name(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::QueueOwner => "queue-owner",
Role::Appender => "queue-appender",
}
}
}
/// Connection flags shared by every remote command. Defaults match the client
/// config; a flag overrides the config-file value.
#[derive(Debug, Clone, Args)]
pub struct RemoteArgs {
/// Server address (default: the client config's, else localhost).
#[arg(short, long)]
pub address: Option<String>,
/// Basic-auth user / role name (empty against an open server).
#[arg(short, long)]
pub user: Option<String>,
/// Basic-auth password.
#[arg(short, long)]
pub password: Option<String>,
}
/// Library operations against a running server.
#[derive(Debug, Subcommand)]
pub enum LibraryCmd {
/// List a node's child nodes and tracks (default path `/`).
List {
#[arg(default_value = "/")]
path: String,
},
/// Create a child node under a creatable parent (e.g. a search term).
Create { parent: String, title: String },
/// Rename an editable node.
Rename { path: String, title: String },
/// Delete a deletable node or track (never touches the content store).
Delete { path: String },
/// Save a subtree as a link bookmark under `/crabidy/<name>`.
Save { path: String, name: String },
/// Capture a subtree under `/crabidy/<name>` (downloads audio).
Capture { path: String, name: String },
}
/// Queue operations against a running server.
/// How `queue sort` orders the queue — the CLI spelling of the wire's
/// `QueueSort` (architecture/queue-order.md D17). A `ValueEnum` so the shell
/// completes it and a typo is a parse error instead of a round trip.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum SortKey {
/// Artist, then album; queue order within an album.
Artist,
/// Album title.
Album,
/// Track title.
Title,
/// Duration; tracks with no known length sort last.
Duration,
/// Reverse the order the queue is in now.
Reverse,
}
#[derive(Debug, Subcommand)]
pub enum QueueCmd {
/// Print the current queue.
Show,
/// Append tracks/subtrees (by library path) to the end of the queue.
Append { paths: Vec<String> },
/// Insert tracks/subtrees at a position, pushing what was there down
/// (0 = front, past the end = append).
Insert { position: u32, paths: Vec<String> },
/// Replace the whole queue with the given tracks/subtrees.
Replace { paths: Vec<String> },
/// Remove queue entries by position.
Remove { positions: Vec<u32> },
/// Clear the queue (optionally keeping the current track).
Clear {
#[arg(long)]
keep_current: bool,
},
/// Drop duplicate entries, keeping one copy of each track. Prints how
/// many were removed; never removes the playing track.
Dedup {
/// Collapse same artist+title instead of the provably-same item: one
/// entry per *song*, keeping the longest take. Aggressive — a remix and
/// the album version count as the same song.
#[arg(long)]
titles: bool,
},
/// Reorder the queue by one strategy (architecture/queue-order.md).
Sort {
#[arg(value_enum)]
key: SortKey,
/// Largest/last first. Ignored by `reverse`; blanks and unknown
/// durations sort last either way.
#[arg(long)]
desc: bool,
},
/// Jump to a queue position.
SetCurrent { position: u32 },
/// Link-save the current queue as `/crabidy/<name>`.
Save { name: String },
/// Capture the current queue into `/crabidy/<name>` (downloads audio).
Capture { name: String },
/// Toggle shuffle.
Shuffle,
/// Toggle repeat.
Repeat,
}
/// Playback and global operations against a running server.
#[derive(Debug, Subcommand)]
pub enum GlobalCmd {
/// Toggle play/pause.
Play,
/// Stop playback.
Stop,
/// Skip to the next track.
Next,
/// Go to the previous track.
Prev,
/// Restart the current track.
Restart,
/// Seek inside the current track by a number of seconds (negative seeks
/// back, e.g. `-15`). Stays within the track: it saturates at the start,
/// and overshooting the end lets the track finish and the queue advance.
Seek {
#[arg(allow_negative_numbers = true)]
seconds: i32,
},
/// Toggle mute.
Mute,
/// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`).
Volume { delta: f32 },
}
/// The remote command groups an executor can run (`client` feature).
#[derive(Debug, Subcommand)]
pub enum RemoteCmd {
/// Library operations.
#[command(subcommand)]
Library(LibraryCmd),
/// Queue operations.
#[command(subcommand)]
Queue(QueueCmd),
/// Playback / global operations.
#[command(subcommand)]
Global(GlobalCmd),
}
/// `guard <role> [password]`: hash a role password and (unless `--no-config`)
/// write it into `crabidy-server.toml`'s `[auth]`.
#[derive(Debug, Args)]
pub struct GuardArgs {
/// Which role's password to set.
pub role: Role,
/// The password. If omitted, it is read from stdin (pipe-friendly, and
/// keeps it out of the process list).
pub password: Option<String>,
/// Only print the PHC hash; do not modify `crabidy-server.toml`.
#[arg(long)]
pub no_config: bool,
}
/// `scan <path> [--capture|--move]`: index a music folder.
#[derive(Debug, Args)]
pub struct ScanArgs {
/// Directory to walk for playable files.
pub path: std::path::PathBuf,
/// Copy each file into the content store; the toml points there.
#[arg(long)]
pub capture: bool,
/// Move each file into the content store instead of copying.
#[arg(long, name = "move")]
pub move_: bool,
}
/// `auth <role> [password]`: write a role + cleartext password into the client
/// config.
#[derive(Debug, Args)]
pub struct AuthArgs {
/// Which role to authenticate as.
pub role: Role,
/// The cleartext password (read from stdin if omitted).
pub password: Option<String>,
/// Also set the server address in the client config.
#[arg(short, long)]
pub address: Option<String>,
}
/// `completions <shell>`: print a completion script to stdout.
#[derive(Debug, Args)]
pub struct CompletionsArgs {
/// Target shell.
pub shell: clap_complete::Shell,
}
/// `audio-devices [device]`: list the output devices, or — with `device` — set
/// `[audio] device` in `crabidy-server.toml`.
#[derive(Debug, Args)]
pub struct AudioDevicesArgs {
/// A name (or case-insensitive fragment) of an output device to write into
/// `[audio] device`. Omit to just list the available devices.
pub device: Option<String>,
}
/// Subcommands of `crabidy-server`.
#[derive(Debug, Subcommand)]
pub enum ServerCommand {
/// Set a role password (hash + write config).
Guard(GuardArgs),
/// Index a music folder with `.cbd-track.toml` files.
Scan(ScanArgs),
/// Library operations against a running server.
#[command(subcommand)]
Library(LibraryCmd),
/// Queue operations against a running server.
#[command(subcommand)]
Queue(QueueCmd),
/// Playback / global operations against a running server.
#[command(subcommand)]
Global(GlobalCmd),
/// List audio output devices, or set `[audio] device` if one is given.
AudioDevices(AudioDevicesArgs),
/// Print the build features this binary was compiled with.
Features,
/// Print a shell completion script.
Completions(CompletionsArgs),
}
/// `crabidy-server`: no subcommand runs the server.
#[derive(Debug, Parser)]
#[command(name = "crabidy-server", author, version, about)]
pub struct ServerCli {
#[command(flatten)]
pub remote: RemoteArgs,
#[command(subcommand)]
pub command: Option<ServerCommand>,
}
/// Subcommands of `cbd-tui`.
#[derive(Debug, Subcommand)]
pub enum TuiCommand {
/// Write a role + password into the client config.
Auth(AuthArgs),
#[command(subcommand)]
Library(LibraryCmd),
#[command(subcommand)]
Queue(QueueCmd),
#[command(subcommand)]
Global(GlobalCmd),
/// Print a shell completion script.
Completions(CompletionsArgs),
}
/// `cbd-tui`: no subcommand runs the TUI.
#[derive(Debug, Parser)]
#[command(name = "cbd-tui", author, version, about)]
pub struct TuiCli {
#[command(flatten)]
pub remote: RemoteArgs,
/// Show the frequency-spectrum bars under the track progress.
#[arg(long)]
pub spectrum: Option<bool>,
#[command(subcommand)]
pub command: Option<TuiCommand>,
}
/// Subcommands of `cbd` — the union of the server and client commands.
#[derive(Debug, Subcommand)]
pub enum CbdCommand {
Guard(GuardArgs),
Scan(ScanArgs),
Auth(AuthArgs),
#[command(subcommand)]
Library(LibraryCmd),
#[command(subcommand)]
Queue(QueueCmd),
#[command(subcommand)]
Global(GlobalCmd),
/// List audio output devices, or set `[audio] device` if one is given.
AudioDevices(AudioDevicesArgs),
/// Print the build features this binary was compiled with.
Features,
/// Print a shell completion script.
Completions(CompletionsArgs),
}
/// `cbd`: no subcommand runs the in-process server + TUI.
#[derive(Debug, Parser)]
#[command(name = "cbd", author, version, about)]
pub struct CbdCli {
#[command(flatten)]
pub remote: RemoteArgs,
#[arg(long)]
pub spectrum: Option<bool>,
#[command(subcommand)]
pub command: Option<CbdCommand>,
}
/// Resolved connection settings for the executor (owned, unlike the TUI's
/// `&'static ServerConfig`).
#[derive(Debug, Clone)]
pub struct Connection {
pub address: String,
pub user: String,
pub password: String,
}
/// Writes bash/zsh/fish completions and a man page for `cmd` into `dir`.
/// Used by each binary's `build.rs` (architecture/cli.md D7).
pub fn generate_assets(
mut cmd: clap::Command,
bin_name: &str,
dir: &std::path::Path,
) -> std::io::Result<()> {
use clap_complete::Shell;
std::fs::create_dir_all(dir.join("completions"))?;
std::fs::create_dir_all(dir.join("man"))?;
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
clap_complete::generate_to(shell, &mut cmd, bin_name, dir.join("completions"))?;
}
let man = clap_mangen::Man::new(cmd);
let mut out = Vec::new();
man.render(&mut out)?;
std::fs::write(dir.join("man").join(format!("{bin_name}.1")), out)?;
Ok(())
}
/// Prints a completion script for `shell` to stdout (the `completions`
/// subcommand).
pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, bin_name: &str) {
clap_complete::generate(shell, cmd, bin_name, &mut std::io::stdout());
}
#[cfg(feature = "client")]
mod client;
#[cfg(feature = "client")]
pub use client::run_remote;
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn no_subcommand_parses_on_every_binary() {
assert!(ServerCli::try_parse_from(["crabidy-server"])
.expect("server")
.command
.is_none());
assert!(TuiCli::try_parse_from(["cbd-tui"])
.expect("tui")
.command
.is_none());
assert!(CbdCli::try_parse_from(["cbd"])
.expect("cbd")
.command
.is_none());
}
#[test]
fn remote_flags_override_when_present_and_are_none_otherwise() {
let cli = TuiCli::try_parse_from(["cbd-tui", "--address", "http://x:1", "-u", "owner"])
.expect("parse");
assert_eq!(cli.remote.address.as_deref(), Some("http://x:1"));
assert_eq!(cli.remote.user.as_deref(), Some("owner"));
assert!(cli.remote.password.is_none());
let bare = TuiCli::try_parse_from(["cbd-tui"]).expect("bare");
assert!(bare.remote.address.is_none());
assert!(bare.remote.user.is_none());
}
#[test]
fn library_list_defaults_to_root() {
let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list"]).expect("parse");
match cli.command {
Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/"),
other => panic!("unexpected: {other:?}"),
}
let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list", "/tidal"])
.expect("parse");
match cli.command {
Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/tidal"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn queue_append_collects_many_paths() {
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "append", "/a", "/b", "/c"])
.expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Append { paths })) => {
assert_eq!(paths, vec!["/a", "/b", "/c"]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn queue_clear_keep_current_is_a_flag() {
let cli =
TuiCli::try_parse_from(["cbd-tui", "queue", "clear", "--keep-current"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Clear { keep_current })) => assert!(keep_current),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn queue_dedup_takes_an_optional_titles_flag() {
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "dedup"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Dedup { titles })) => {
assert!(!titles, "the safe identity unless asked")
}
other => panic!("unexpected: {other:?}"),
}
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "dedup", "--titles"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Dedup { titles })) => assert!(titles),
other => panic!("unexpected: {other:?}"),
}
}
/// The strategy is a value enum, so it is spelled lowercase, completes in
/// a shell, and a typo fails at parse time rather than as a server
/// `InvalidArgument` (architecture/queue-order.md D17).
#[test]
fn queue_sort_parses_a_strategy_and_an_optional_direction() {
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "artist"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Sort { key, desc })) => {
assert_eq!(key, SortKey::Artist);
assert!(!desc, "ascending unless asked");
}
other => panic!("unexpected: {other:?}"),
}
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "duration", "--desc"])
.expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Sort { key, desc })) => {
assert_eq!(key, SortKey::Duration);
assert!(desc);
}
other => panic!("unexpected: {other:?}"),
}
assert!(
TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "artiste"]).is_err(),
"a misspelled strategy must not reach the server"
);
assert!(
TuiCli::try_parse_from(["cbd-tui", "queue", "sort"]).is_err(),
"the strategy is required"
);
}
#[test]
fn global_volume_parses_a_signed_delta() {
let cli =
TuiCli::try_parse_from(["cbd-tui", "global", "volume", "--", "-0.1"]).expect("parse");
match cli.command {
Some(TuiCommand::Global(GlobalCmd::Volume { delta })) => {
assert!((delta - -0.1).abs() < f32::EPSILON);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn guard_reads_a_role_and_optional_password() {
let cli = ServerCli::try_parse_from(["crabidy-server", "guard", "owner"]).expect("parse");
match cli.command {
Some(ServerCommand::Guard(args)) => {
assert_eq!(args.role, Role::Owner);
assert!(args.password.is_none());
assert!(!args.no_config);
}
other => panic!("unexpected: {other:?}"),
}
let cli = ServerCli::try_parse_from([
"crabidy-server",
"guard",
"queue-owner",
"secret",
"--no-config",
])
.expect("parse");
match cli.command {
Some(ServerCommand::Guard(args)) => {
assert_eq!(args.role, Role::QueueOwner);
assert_eq!(args.password.as_deref(), Some("secret"));
assert!(args.no_config);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn scan_flags_parse() {
let cli = ServerCli::try_parse_from(["crabidy-server", "scan", "/music", "--capture"])
.expect("p");
match cli.command {
Some(ServerCommand::Scan(args)) => {
assert_eq!(args.path, std::path::PathBuf::from("/music"));
assert!(args.capture);
assert!(!args.move_);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn cbd_is_the_union_of_server_and_client_commands() {
assert!(matches!(
CbdCli::try_parse_from(["cbd", "guard", "owner"])
.expect("p")
.command,
Some(CbdCommand::Guard(_))
));
assert!(matches!(
CbdCli::try_parse_from(["cbd", "auth", "owner", "pw"])
.expect("p")
.command,
Some(CbdCommand::Auth(_))
));
assert!(matches!(
CbdCli::try_parse_from(["cbd", "global", "play"])
.expect("p")
.command,
Some(CbdCommand::Global(GlobalCmd::Play))
));
}
}

View File

@ -1,45 +1,17 @@
[package]
name = "cbd-tui"
version.workspace = true
edition.workspace = true
version = "0.1.0"
edition = "2021"
# 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", "mpris"]
notifications = ["dep:notify-rust"]
# The MPRIS player on the session bus: media keys and a "now playing"
# entry in the desktop's status bar (architecture/mpris.md). `zbus/tokio`
# so the bus connection runs on the client's own runtime instead of a
# second reactor (architecture/mpris.md, Risks).
mpris = ["dep:mpris-server", "mpris-server/tokio"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
base64.workspace = true
cbd-cli = { workspace = true, features = ["client"] }
crabidy-core.workspace = true
crossterm.workspace = true
clap.workspace = true
dirs.workspace = true
toml.workspace = true
flume.workspace = true
libc.workspace = true
mpris-server = { workspace = true, optional = true }
notify-rust = { workspace = true, optional = true }
ratatui.workspace = true
serde.workspace = true
tokio = { workspace = true, features = ["full"] }
tokio-stream.workspace = true
tonic = { workspace = true, features = ["channel", "codegen"] }
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true
# Default features only (clap-only) so asset generation stays cheap.
[build-dependencies]
cbd-cli.workspace = true
clap.workspace = true
crossterm = "0.26.1"
crabidy-core = { path = "../crabidy-core" }
flume = "0.10.14"
ratatui = "0.20.1"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tonic = "0.9"
notify-rust = "4.8.0"
serde = "1.0.164"

View File

@ -1,23 +0,0 @@
//! Generates shell completions and a man page for `cbd-tui` from its
//! top-level clap command (architecture/cli.md D7). Written into `OUT_DIR`
//! every build, and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli`
//! is a default-features (clap-only) build-dependency, so this never pulls
//! tonic into ordinary builds.
use std::path::Path;
fn main() {
use clap::CommandFactory;
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
let bin = "cbd-tui";
let command = cbd_cli::TuiCli::command();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
}
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,246 +0,0 @@
//! The help modal: a centered overlay listing usage notes and all key
//! bindings, rendered entirely from [`super::bindings::BINDINGS`].
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use super::bindings::{key_label, Scope, BINDINGS};
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
/// The modal's content: a usage blurb, two binding columns, and a close-keys
/// footer derived from the `Scope::Help` bindings.
///
/// The columns are balanced by row count, not by scope order: Global + Queue
/// on the left, Library on the right. With marks and the register the Queue
/// group is nearly as long as the Library group, and pairing the two of them
/// in one column overflowed any normal terminal.
struct HelpContent {
usage: Vec<Line<'static>>,
left: Vec<Line<'static>>,
right: Vec<Line<'static>>,
footer: Line<'static>,
}
impl HelpContent {
fn build() -> Self {
let usage = vec![
Line::from("Browse the library (left pane) and manage the play queue (right pane)."),
Line::from("Press Tab to switch focus; keys apply globally or to the focused pane."),
Line::from(""),
];
let mut left = group(Scope::Global, "Global");
left.push(Line::from(""));
left.extend(group(Scope::Queue, "Queue"));
let right = group(Scope::Library, "Library");
// All Help-scope chords close the modal; derive their labels instead
// of hardcoding key names.
let close_keys = BINDINGS
.iter()
.filter(|b| b.scope == Scope::Help)
.map(|b| key_label(b.mods, b.code))
.collect::<Vec<_>>()
.join(", ");
let footer = Line::from(Span::styled(
format!("Close help: {close_keys}"),
Style::default().fg(COLOR_SECONDARY),
));
Self {
usage,
left,
right,
footer,
}
}
fn left_width(&self) -> u16 {
max_width(&self.left)
}
/// Content size excluding the popup borders.
fn size(&self) -> (u16, u16) {
let columns = self.left_width() + COLUMN_GAP + max_width(&self.right);
let width = columns
.max(max_width(&self.usage))
.max(self.footer.width() as u16);
let height = self.usage.len() as u16
+ (self.left.len().max(self.right.len()) as u16)
+ 2 // blank line + footer
;
(width, height)
}
}
const COLUMN_GAP: u16 = 2;
fn max_width(lines: &[Line<'_>]) -> u16 {
lines.iter().map(|l| l.width() as u16).max().unwrap_or(0)
}
/// One scope's bindings as a styled header plus `key description` rows,
/// with key labels right-aligned to the group's widest label.
fn group(scope: Scope, title: &'static str) -> Vec<Line<'static>> {
let entries: Vec<_> = BINDINGS.iter().filter(|b| b.scope == scope).collect();
let key_width = entries
.iter()
.map(|b| key_label(b.mods, b.code).chars().count())
.max()
.unwrap_or(0);
let mut lines = vec![Line::from(Span::styled(
title,
Style::default()
.fg(COLOR_SECONDARY)
.add_modifier(Modifier::BOLD),
))];
lines.extend(entries.iter().map(|b| {
Line::from(vec![
Span::styled(
format!("{:>key_width$}", key_label(b.mods, b.code)),
Style::default().fg(COLOR_PRIMARY),
),
Span::from(format!(" {}", b.description)),
])
}));
lines
}
/// Render the help modal over the current frame.
///
/// Draws a `Clear`-backed, centered popup on top of whatever is already in
/// the frame. If the frame is smaller than the content, the popup is clamped
/// to the frame and overflowing lines are truncated (no scrolling — see
/// architecture/help-modal.md, open questions).
pub fn render(f: &mut Frame) {
let content = HelpContent::build();
let area = popup_area(f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(COLOR_PRIMARY))
.title("Help");
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(content.usage.len() as u16),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner);
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(content.left_width() + COLUMN_GAP),
Constraint::Min(0),
])
.split(rows[1]);
f.render_widget(Paragraph::new(content.usage.clone()), rows[0]);
f.render_widget(Paragraph::new(content.left.clone()), columns[0]);
f.render_widget(Paragraph::new(content.right.clone()), columns[1]);
f.render_widget(Paragraph::new(vec![content.footer.clone()]), rows[2]);
}
/// The centered popup rectangle: sized to the help content but clamped to
/// `frame`, never exceeding it.
fn popup_area(frame: Rect) -> Rect {
let (content_w, content_h) = HelpContent::build().size();
let width = content_w.saturating_add(2).min(frame.width);
let height = content_h.saturating_add(2).min(frame.height);
Rect::new(
frame.x + (frame.width - width) / 2,
frame.y + (frame.height - height) / 2,
width,
height,
)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
fn render_to_buffer(width: u16, height: u16) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(render).expect("draw help");
terminal.backend().buffer().clone()
}
fn buffer_text(buf: &ratatui::buffer::Buffer) -> String {
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
#[test]
fn help_lists_bindings_from_the_table() {
// Tall enough for the whole table: the two columns now need ~43 rows
// (see `a_short_frame_truncates_rather_than_panicking`).
let text = buffer_text(&render_to_buffer(100, 50));
// Spot-check one entry per scope, by description from BINDINGS.
assert!(text.contains("Quit"));
assert!(text.contains("Enter selected folder"));
assert!(text.contains("Remove selected track"));
assert!(text.contains("Paste the register after this track"));
assert!(text.contains("Close help"));
}
/// The modal clamps to the frame and truncates; it does not scroll and
/// does not panic. Pins the known limitation rather than hiding it: the
/// full table needs more rows than a short terminal has.
#[test]
fn a_short_frame_truncates_rather_than_panicking() {
let text = buffer_text(&render_to_buffer(100, 20));
// The head of the left column is still there…
assert!(text.contains("Quit"));
// …and the tail of the longest column is not.
assert!(!text.contains("Paste the register after this track"));
}
#[test]
fn help_explains_basic_usage() {
let text = buffer_text(&render_to_buffer(100, 40));
// The usage blurb must mention the panes and how to switch focus.
assert!(text.contains("Tab"));
assert!(text.to_lowercase().contains("library"));
assert!(text.to_lowercase().contains("queue"));
}
#[test]
fn help_survives_tiny_terminals() {
// Truncation, not panic, on frames smaller than the content.
for (w, h) in [(10, 5), (20, 10), (1, 1)] {
let _ = render_to_buffer(w, h);
}
}
#[test]
fn popup_never_exceeds_the_frame() {
for (w, h) in [(100, 40), (30, 12), (5, 3)] {
let frame = Rect::new(0, 0, w, h);
let popup = popup_area(frame);
assert!(popup.right() <= frame.right());
assert!(popup.bottom() <= frame.bottom());
}
}
}

View File

@ -2,6 +2,7 @@ use std::collections::HashMap;
use flume::Sender;
use ratatui::{
backend::Backend,
layout::Rect,
style::{Modifier, Style},
text::Span,
@ -12,28 +13,16 @@ use ratatui::{
use crabidy_core::proto::crabidy::LibraryNode;
use super::{
Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
};
pub struct Library {
title: String,
path: String,
/// Whether children may be created under the currently open node
/// (mirrors `LibraryNode.is_creatable`). Drives the `%` action and the
/// pane-title hint.
is_creatable: bool,
uuid: String,
list: Vec<UiItem>,
list_state: ListState,
/// The `/` search filter; selection and rendering go through it.
filter: Filter,
parent: Option<String>,
positions: HashMap<String, usize>,
/// Visual (paint-select) mode: `Some(anchor_view)` while active. Movement
/// marks the contiguous range between the anchor (where `v` was pressed)
/// and the cursor, toggling rows as they enter/leave it — so moving back
/// cleanly reverses (architecture/visual-mode.md).
visual: Option<usize>,
tx: Sender<MessageFromUi>,
}
@ -41,92 +30,40 @@ impl Library {
pub fn new(tx: Sender<MessageFromUi>) -> Self {
Self {
title: "Library".to_string(),
path: crabidy_core::ROOT_PATH.to_string(),
is_creatable: false,
uuid: "node:/".to_string(),
list: Vec::new(),
list_state: ListState::default(),
filter: Filter::default(),
positions: HashMap::new(),
parent: None,
visual: None,
tx,
}
}
/// The item under the cursor, mapped through the active filter.
fn resolved(&self) -> Option<&UiItem> {
let real = self.filter.to_real(self.list_state.selected()?)?;
self.list.get(real)
}
/// Sets (or clears) the `/` search query and re-selects the first
/// match so the cursor never points at a now-hidden row.
pub fn set_filter(&mut self, query: Option<String>) {
self.filter
.set(query, self.list.iter().map(|i| i.title.as_str()));
self.update_selection();
}
/// The active search query, if the pane is in search mode.
pub fn filter_query(&self) -> Option<&str> {
self.filter.query()
}
/// Path of the currently open node.
pub fn path(&self) -> &str {
&self.path
}
/// Whether the currently open node accepts child creation (`%`).
pub fn is_creatable(&self) -> bool {
self.is_creatable
}
/// Path and current title of the selected item, if it may be renamed
/// (`e`). `None` when nothing is selected or the item is not editable.
pub fn selected_editable(&self) -> Option<(String, String)> {
let item = self.resolved()?;
item.is_editable
.then(|| (item.path.clone(), item.title.clone()))
}
/// Path and title of the selected item, if it may be deleted (`d`).
/// `None` when nothing is selected or the item is not deletable.
pub fn selected_deletable(&self) -> Option<(String, String)> {
let item = self.resolved()?;
item.is_deletable
.then(|| (item.path.clone(), item.title.clone()))
}
/// Path and title of the bare selection, if it is queueable — what `w`
/// captures as a bookmark. Marks are deliberately ignored: one capture
/// per invocation (architecture/bookmarks.md D5).
pub fn selected_queueable(&self) -> Option<(String, String)> {
let item = self.resolved()?;
item.is_queable
.then(|| (item.path.clone(), item.title.clone()))
}
/// The bare selection's path and title when it can be captured with a
/// download (`W`): queueable *and* downloadable. Marks are ignored,
/// like [`Self::selected_queueable`].
pub fn selected_downloadable(&self) -> Option<(String, String)> {
let item = self.resolved()?;
(item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
}
/// The paths a queue action ships — the marked rows, or the gated cursor
/// row. Thin wrapper over [`MarkedPane::selection`], which both panes
/// share.
pub fn get_selected(&self) -> Option<Vec<String>> {
self.selection().map(|(paths, _labels)| paths)
if self.list.iter().any(|i| i.marked) {
return Some(
self.list
.iter()
.filter(|i| i.marked)
.map(|i| i.uuid.to_string())
.collect(),
);
}
if let Some(idx) = self.list_state.selected() {
return Some(vec![self.list[idx].uuid.to_string()]);
}
None
}
pub fn ascend(&mut self) {
if let Some(parent) = self.parent.as_ref() {
let _ = self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
}
}
pub fn dive(&mut self) {
if let Some(item) = self.resolved() {
if let Some(idx) = self.list_state.selected() {
let item = &self.list[idx];
if let UiItemKind::Node = item.kind {
let _ = self
.tx
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
self.tx
.send(MessageFromUi::GetLibraryNode(item.uuid.clone()));
}
}
}
@ -154,133 +91,93 @@ impl Library {
}
}
}
pub fn queue_insert(&mut self, pos: usize) {
if let Some(items) = self.get_selected() {
match self.tx.send(MessageFromUi::InsertTracks(items, pos)) {
Ok(_) => self.remove_marks(),
Err(_) => { /* FIXME: warn */ }
}
}
}
pub fn prev_selected(&self) -> usize {
*self.positions.get(&self.path).unwrap_or(&0)
*self.positions.get(&self.uuid).unwrap_or(&0)
}
/// Titles of the currently marked rows, in list order (inspection helper).
pub fn marked_titles(&self) -> Vec<String> {
self.list
.iter()
.filter(|i| i.marked)
.map(|i| i.title.clone())
.collect()
pub fn toggle_mark(&mut self) {
if let Some(idx) = self.list_state.selected() {
let mut item = &mut self.list[idx];
if !item.is_queable {
return;
}
item.marked = !item.marked;
}
}
pub fn remove_marks(&mut self) {
if self.list.iter().any(|i| i.marked) {
self.list
.iter_mut()
.filter(|i| i.marked)
.for_each(|i| i.marked = false);
}
}
pub fn update(&mut self, node: LibraryNode) {
// Creatable nodes (e.g. an empty search node) must be enterable even
// with nothing in them — the user goes there to create children.
if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() {
if node.tracks.is_empty() && node.children.is_empty() {
return;
}
// if children empty and tracks empty return
self.path = node.path;
self.uuid = node.uuid;
self.title = node.title;
self.parent = node.parent;
self.is_creatable = node.is_creatable;
// Most nodes carry either children or tracks; search term nodes
// carry both (track results + artist/album results), so the list is
// the concatenation. Tracks first: they are the primary search hits
// (architecture/search.md fixes the order tracks, artists, albums).
self.list = node
.tracks
.iter()
.map(|t| UiItem {
path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: false,
is_queable: true,
is_creatable: false,
is_editable: false,
// Tracks carry no wire flags of their own: they inherit
// their node's blessing (architecture/captures.md D4).
is_deletable: node.tracks_deletable,
is_downloadable: node.is_downloadable,
is_skipped: t.is_skipped,
is_captured: t.is_captured,
})
.chain(node.children.iter().map(|c| UiItem {
path: c.path.clone(),
title: c.title.clone(),
kind: UiItemKind::Node,
marked: false,
is_queable: c.is_queable,
is_creatable: c.is_creatable,
is_editable: c.is_editable,
is_deletable: c.is_deletable,
is_downloadable: c.is_downloadable,
is_skipped: false,
is_captured: c.is_captured,
}))
.collect();
// A new node is a fresh listing: leave search mode and show all.
self.filter
.set(None, self.list.iter().map(|i| i.title.as_str()));
// With no filter the view index is the real index, so the
// remembered cursor position restores directly.
self.select(Some(self.prev_selected()));
if !node.tracks.is_empty() {
self.list = node
.tracks
.iter()
.map(|t| UiItem {
uuid: t.uuid.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: false,
is_queable: true,
})
.collect();
} else {
// if tracks not empty use tracks instead
self.list = node
.children
.iter()
.map(|c| UiItem {
uuid: c.uuid.clone(),
title: c.title.clone(),
kind: UiItemKind::Node,
marked: false,
is_queable: c.is_queable,
})
.collect();
}
self.update_selection();
}
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
let selected = self.list_state.selected();
// Render only the rows the filter keeps visible; `view` is the
// rendered index the selection bar keys off, `i` the real item.
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
let library_items: Vec<ListItem> = self
.filter
.visible()
.list
.iter()
.map(|&real| &self.list[real])
.enumerate()
.map(|(idx, i)| {
let mut text = if i.marked {
.map(|i| {
let text = if i.marked {
format!("* {}", i.title)
} else {
i.title.to_string()
};
if i.is_creatable {
text.push_str(" [%]");
}
// Modifiable items advertise their action keys: [e], [d] or
// [ed].
if i.is_editable || i.is_deletable {
text.push_str(" [");
if i.is_editable {
text.push('e');
}
if i.is_deletable {
text.push('d');
}
text.push(']');
}
// A trailing `↓` (a status, not an action, so kept outside
// the key brackets) marks a row whose audio is in the content
// store (downloaded).
if i.is_captured {
text.push_str("");
}
let mut style = if i.marked {
let style = if i.marked {
Style::default()
.fg(COLOR_GREEN)
.add_modifier(Modifier::BOLD)
} else if i.is_skipped {
// Skipped tracks have no playable audio; playback
// skips them (architecture/incremental-captures.md).
Style::default().fg(COLOR_RED)
} else if i.is_creatable || i.is_editable || i.is_deletable {
Style::default().fg(COLOR_SECONDARY)
} else {
Style::default()
};
// A colored foreground is unreadable on the light focused
// selection bar — switch it to the dark tone there (D7).
if focused && selected == Some(idx) && style.fg.is_some() {
style = style.fg(COLOR_PRIMARY_DARK);
}
ListItem::new(Span::from(text)).style(style)
return ListItem::new(Span::from(text)).style(style);
})
.collect();
@ -294,17 +191,7 @@ impl Library {
} else {
COLOR_PRIMARY_DARK
}))
.title(if self.visual.is_some() {
// Visual (paint-select) mode: movement toggles marks.
format!("{} — VISUAL", self.title)
} else if let Some(query) = self.filter.query() {
// Search mode: show the live query with a cursor.
format!("{} — /{query}", self.title)
} else if self.is_creatable {
format!("{} — % to add", self.title)
} else {
self.title.clone()
}),
.title(self.title.clone()),
)
.highlight_style(
Style::default()
@ -322,21 +209,15 @@ impl Library {
impl StatefulList for Library {
fn get_size(&self) -> usize {
// Navigation operates on the filtered (visible) view.
self.filter.view_len()
self.list.len()
}
fn select(&mut self, idx: Option<usize>) {
// Remember the cursor per node as a real index so it survives a
// filter (which only reorders the view). With no filter, the
// view index is already the real index.
if let Some(view) = idx {
if let Some(real) = self.filter.to_real(view) {
self.positions
.entry(self.path.clone())
.and_modify(|e| *e = real)
.or_insert(real);
}
if let Some(pos) = idx {
self.positions
.entry(self.uuid.clone())
.and_modify(|e| *e = pos)
.or_insert(pos);
}
self.list_state.select(idx);
}
@ -345,79 +226,3 @@ impl StatefulList for Library {
self.list_state.selected()
}
}
impl MarkedPane for Library {
fn items(&self) -> &[UiItem] {
&self.list
}
fn items_mut(&mut self) -> &mut [UiItem] {
&mut self.list
}
fn filter(&self) -> &Filter {
&self.filter
}
fn visual(&self) -> Option<usize> {
self.visual
}
fn set_visual(&mut self, anchor: Option<usize>) {
self.visual = anchor;
}
fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Only queueable rows may be marked: a marked plain folder would ship a
/// path the server can resolve to nothing.
fn markable(&self, item: &UiItem) -> bool {
item.is_queable
}
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::LibraryNodeChild;
/// Renders a `Library` into an 80x24 test terminal and returns its text.
fn render_text(library: &mut Library) -> String {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| library.render(f, f.area(), true))
.expect("draw library");
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
#[test]
fn captured_rows_render_a_trailing_arrow() {
let (tx, _rx) = flume::unbounded();
let mut library = Library::new(tx);
library.update(LibraryNode {
path: "/crabidy/mix".to_string(),
title: "mix".to_string(),
children: vec![LibraryNodeChild {
is_captured: true,
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
}],
parent: Some("/crabidy".to_string()),
tracks: Vec::new(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
});
let text = render_text(&mut library);
assert!(
text.contains("album ↓"),
"captured row carries a trailing down-arrow: {text}"
);
}
}

View File

@ -1,74 +1,4 @@
/// A case-insensitive substring filter over a pane's item list (the
/// `/` search, shared by the library and queue panes).
///
/// The pane keeps its full item list; the filter only records which
/// real indices are currently visible, so navigation and — crucially
/// for the queue — the real positions sent to the server stay correct.
/// An *active* filter with an empty query shows everything (search mode
/// is on, nothing typed yet); an inactive filter also shows everything.
#[derive(Default)]
pub struct Filter {
/// `None` = not searching; `Some(query)` = search mode, query so far.
query: Option<String>,
/// Real indices currently visible, in list order. Rebuilt by
/// [`Self::recompute`]; always `0..len` while inactive or empty.
visible: Vec<usize>,
}
impl Filter {
/// Whether search mode is on (the query line is showing).
pub fn is_active(&self) -> bool {
self.query.is_some()
}
/// The current query, if searching.
pub fn query(&self) -> Option<&str> {
self.query.as_deref()
}
/// Enters/updates/leaves search mode and recomputes the visible set
/// against `titles` (the pane's full list, in order).
pub fn set<'a>(&mut self, query: Option<String>, titles: impl Iterator<Item = &'a str>) {
self.query = query;
self.recompute(titles);
}
/// Recomputes the visible indices from the current query against
/// `titles`. Call whenever the underlying list changes.
pub fn recompute<'a>(&mut self, titles: impl Iterator<Item = &'a str>) {
match self.query.as_deref().filter(|q| !q.is_empty()) {
None => self.visible = titles.enumerate().map(|(i, _)| i).collect(),
Some(query) => {
let needle = query.to_lowercase();
self.visible = titles
.enumerate()
.filter(|(_, title)| title.to_lowercase().contains(&needle))
.map(|(i, _)| i)
.collect();
}
}
}
/// Number of visible rows.
pub fn view_len(&self) -> usize {
self.visible.len()
}
/// The real list index behind a view (rendered) index.
pub fn to_real(&self, view: usize) -> Option<usize> {
self.visible.get(view).copied()
}
/// The view index showing a given real list index, if it is visible.
pub fn to_view(&self, real: usize) -> Option<usize> {
self.visible.iter().position(|&i| i == real)
}
/// The visible real indices, in order — for rendering.
pub fn visible(&self) -> &[usize] {
&self.visible
}
}
pub use ratatui::widgets::ListState;
// FIXME: Move marking stuff here, to be able to use it in queue as well
pub trait StatefulList {
@ -142,6 +72,10 @@ pub trait StatefulList {
}
}
fn is_selected(&self) -> bool {
self.selected().is_some()
}
fn is_empty(&self) -> bool {
self.get_size() == 0
}
@ -163,256 +97,3 @@ pub trait StatefulList {
}
}
}
/// Marks and visual mode for a pane that owns a `Vec<UiItem>` and a `/`
/// filter — shared by the library and the queue
/// (architecture/queue-register.md D7).
///
/// The required methods are the pane's own state; everything else is
/// behaviour both panes must agree on. Marks live on the **full** list, so a
/// marked-but-filtered-out row still counts; the view indices these methods
/// take are mapped through the filter.
pub(crate) trait MarkedPane {
/// Every row, in real (unfiltered) order.
fn items(&self) -> &[super::UiItem];
fn items_mut(&mut self) -> &mut [super::UiItem];
fn filter(&self) -> &Filter;
/// The visual-mode anchor (a **view** index), or `None` when off.
fn visual(&self) -> Option<usize>;
fn set_visual(&mut self, anchor: Option<usize>);
/// The cursor's **view** index.
fn selected_view(&self) -> Option<usize>;
/// Whether a row may be marked at all. The library gates on
/// `is_queable`; the queue allows every row.
fn markable(&self, item: &super::UiItem) -> bool;
fn is_visual(&self) -> bool {
self.visual().is_some()
}
/// Enter or leave visual mode. Entering anchors at the cursor and toggles
/// its mark (vim includes the row you start on); leaving keeps the marks.
fn toggle_visual(&mut self) {
if self.is_visual() {
self.set_visual(None);
} else {
self.set_visual(Some(self.selected_view().unwrap_or(0)));
self.toggle_mark();
}
}
fn exit_visual(&mut self) {
self.set_visual(None);
}
/// Toggle the mark of the cursor row.
fn toggle_mark(&mut self) {
if let Some(view) = self.selected_view() {
self.toggle_mark_view(view);
}
}
/// Toggle the mark of one **view** row, honoring [`Self::markable`].
fn toggle_mark_view(&mut self, view: usize) {
let Some(real) = self.filter().to_real(view) else {
return;
};
let Some(item) = self.items().get(real) else {
return;
};
if !self.markable(item) {
return;
}
if let Some(item) = self.items_mut().get_mut(real) {
item.marked = !item.marked;
}
}
/// Paint a visual-mode sweep. The selection is the contiguous range
/// `[anchor, cursor]`; a move that grows or shrinks it toggles exactly the
/// rows whose range membership changed, so moving back reverses a move
/// cleanly and the row you turn around on is never stranded. No-op unless
/// visual mode is active.
fn paint_between(&mut self, from_view: usize, to_view: usize) {
let Some(anchor) = self.visual() else {
return;
};
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&view);
let in_new = (new_lo..=new_hi).contains(&view);
if in_old != in_new {
self.toggle_mark_view(view);
}
}
}
fn has_marks(&self) -> bool {
self.items().iter().any(|i| i.marked)
}
fn remove_marks(&mut self) {
for item in self.items_mut() {
item.marked = false;
}
}
/// The rows an action applies to: every marked row, or the cursor row
/// when nothing is marked. Returns `(paths, labels)` — labels are for
/// display only. `None` when nothing applies (an unmarkable cursor row).
///
/// Marks live on the full list, so a marked-but-filtered-out row still
/// counts: the filter narrows what you *see*, not what you already chose.
fn selection(&self) -> Option<(Vec<String>, Vec<String>)> {
if self.has_marks() {
let marked = self.items().iter().filter(|i| i.marked);
return Some((
marked.clone().map(|i| i.path.clone()).collect(),
marked.map(|i| i.title.clone()).collect(),
));
}
// The bare cursor row must pass the same gate the marks do, or a
// plain folder would ship a path the server resolves to nothing.
let real = self.filter().to_real(self.selected_view()?)?;
let item = self.items().get(real)?;
self.markable(item)
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
}
}
/// Carry queue marks across a server snapshot by a greedy in-order match on
/// track path (architecture/queue-register.md D5).
///
/// Returns the mark flags for `new_paths`. A mark follows its track through
/// appends, removals, and playback advancing; a mark whose track is gone is
/// dropped. Duplicate paths are inherently ambiguous — the *n*-th occurrence
/// keeps the *n*-th occurrence's mark. When the two lists share no marked
/// track at all, the result is all-unmarked rather than a guess.
pub(crate) fn carry_marks(
old_paths: &[String],
old_marked: &[bool],
new_paths: &[String],
) -> Vec<bool> {
let mut carried = vec![false; new_paths.len()];
// Walk both lists forward, pairing equal paths. Insertions in `new` and
// removals from `old` are skipped over, so a mark follows its own track
// rather than its old index; duplicates pair up in order.
let mut old_idx = 0;
for (new_idx, path) in new_paths.iter().enumerate() {
while old_idx < old_paths.len() && &old_paths[old_idx] != path {
old_idx += 1;
}
if old_idx < old_paths.len() {
// A short `old_marked` (caller bookkeeping drift) reads as
// unmarked rather than panicking.
carried[new_idx] = old_marked.get(old_idx).copied().unwrap_or(false);
old_idx += 1;
}
}
carried
}
#[cfg(test)]
mod carry_marks_tests {
use super::carry_marks;
fn paths(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
/// The plain case: nothing moved, marks stay put.
#[test]
fn an_unchanged_queue_keeps_its_marks() {
let old = paths(&["/a", "/b", "/c"]);
assert_eq!(
carry_marks(&old, &[false, true, false], &old),
vec![false, true, false]
);
}
/// Another client appended: marks must not slide.
#[test]
fn an_append_leaves_earlier_marks_alone() {
let old = paths(&["/a", "/b"]);
let new = paths(&["/a", "/b", "/c"]);
assert_eq!(
carry_marks(&old, &[false, true], &new),
vec![false, true, false]
);
}
/// Playback advanced and dropped the head: the mark follows its track to
/// its new index instead of staying on a number.
#[test]
fn a_removal_before_a_mark_shifts_it_down() {
let old = paths(&["/a", "/b", "/c"]);
let new = paths(&["/b", "/c"]);
assert_eq!(
carry_marks(&old, &[false, false, true], &new),
vec![false, true]
);
}
/// The marked track itself is gone (we just deleted it).
#[test]
fn a_removed_marked_track_drops_its_mark() {
let old = paths(&["/a", "/b", "/c"]);
let new = paths(&["/a", "/c"]);
assert_eq!(
carry_marks(&old, &[false, true, false], &new),
vec![false, false]
);
}
/// A resolve streaming in grows the tail; a selection made meanwhile
/// survives.
#[test]
fn a_streaming_resolve_keeps_the_selection() {
let old = paths(&["/a", "/b"]);
let new = paths(&["/a", "/b", "/c", "/d", "/e"]);
assert_eq!(
carry_marks(&old, &[true, true], &new),
vec![true, true, false, false, false]
);
}
/// The same track queued twice: the n-th occurrence keeps the n-th mark.
#[test]
fn duplicate_paths_match_in_order() {
let old = paths(&["/a", "/a", "/a"]);
let new = paths(&["/a", "/a", "/a"]);
assert_eq!(
carry_marks(&old, &[false, true, false], &new),
vec![false, true, false]
);
}
/// A wholesale replacement shares nothing: clear rather than guess.
#[test]
fn a_replaced_queue_clears_marks() {
let old = paths(&["/a", "/b", "/c"]);
let new = paths(&["/x", "/y"]);
assert_eq!(carry_marks(&old, &[true, true, true], &new), vec![false; 2]);
}
#[test]
fn an_emptied_queue_has_no_marks() {
let old = paths(&["/a", "/b"]);
assert!(carry_marks(&old, &[true, true], &[]).is_empty());
}
/// A first snapshot has no history to carry.
#[test]
fn no_previous_marks_yields_none() {
let new = paths(&["/a", "/b"]);
assert_eq!(carry_marks(&[], &[], &new), vec![false, false]);
}
/// Length mismatches in the caller's bookkeeping must not panic.
#[test]
fn a_short_mark_vector_is_tolerated() {
let old = paths(&["/a", "/b", "/c"]);
assert_eq!(carry_marks(&old, &[true], &old), vec![true, false, false]);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
use flume::Sender;
use ratatui::{
backend::Backend,
layout::Rect,
style::{Modifier, Style},
text::Span,
@ -10,196 +11,43 @@ use ratatui::{
use crabidy_core::proto::crabidy::Queue as QueueData;
use super::{
carry_marks, Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED,
};
pub struct Queue {
current_position: usize,
list: Vec<UiItem>,
list_state: ListState,
/// The `/` search filter; selection and rendering go through it, and
/// it maps view rows back to real queue positions before they are
/// sent to the server.
filter: Filter,
/// True while the server is still resolving queued paths (from
/// `Queue.resolving`); the pane then renders an animated-dots
/// pseudo-item after the last track. The pseudo-item exists only at
/// render time — it never enters `list`, so selection and removal
/// cannot reach it.
resolving: bool,
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
/// as in the library (architecture/queue-register.md D7).
visual: Option<usize>,
/// The result of the last dedup and when it arrived: shown in the pane
/// title for [`NOTICE_LINGER`], then gone
/// (architecture/queue-order.md D15).
notice: Option<(String, std::time::Instant)>,
tx: Sender<MessageFromUi>,
}
/// How long a dedup result stays in the pane title.
const NOTICE_LINGER: std::time::Duration = std::time::Duration::from_secs(4);
impl Queue {
pub fn new(tx: Sender<MessageFromUi>) -> Self {
Self {
current_position: 0,
list: Vec::new(),
list_state: ListState::default(),
filter: Filter::default(),
resolving: false,
visual: None,
notice: None,
tx,
}
}
/// Shows how many entries a dedup removed, in the pane title, for a few
/// seconds (architecture/queue-order.md D15). `0` is worth saying: it
/// means the queue held no duplicates.
pub fn show_dedup_result(&mut self, removed: u32) {
let text = match removed {
0 => "no duplicates".to_string(),
1 => "removed 1 duplicate".to_string(),
n => format!("removed {n} duplicates"),
};
self.notice = Some((text, std::time::Instant::now()));
}
/// The title note to render right now, or `None` once it has expired.
/// Separate from [`Self::show_dedup_result`] so the expiry is checked at
/// render time rather than on a timer.
fn notice(&self) -> Option<&str> {
self.notice
.as_ref()
.filter(|(_, at)| at.elapsed() < NOTICE_LINGER)
.map(|(text, _)| text.as_str())
}
/// The real queue position under the cursor, mapped through the
/// active filter — this is what the server-facing ops send.
fn selected_position(&self) -> Option<usize> {
self.filter.to_real(self.list_state.selected()?)
}
/// Sets (or clears) the `/` search query and re-selects a valid row.
pub fn set_filter(&mut self, query: Option<String>) {
self.filter
.set(query, self.list.iter().map(|i| i.title.as_str()));
self.update_selection();
}
/// The active search query, if the pane is in search mode.
pub fn filter_query(&self) -> Option<&str> {
self.filter.query()
}
/// The loading indicator line: one to three dots, cycling with wall
/// time (~400 ms per step). Pure so the animation is testable.
fn loading_dots(elapsed_ms: u128) -> String {
".".repeat(1 + (elapsed_ms / 400 % 3) as usize)
}
pub fn play_next(&self) {
let _ = self.tx.send(MessageFromUi::NextTrack);
self.tx.send(MessageFromUi::NextTrack);
}
pub fn play_prev(&self) {
let _ = self.tx.send(MessageFromUi::PrevTrack);
self.tx.send(MessageFromUi::PrevTrack);
}
pub fn play_selected(&self) {
if let Some(pos) = self.selected_position() {
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
if let Some(pos) = self.selected() {
self.tx.send(MessageFromUi::SetCurrentTrack(pos));
}
}
pub fn select_current(&mut self) {
// Map the real playing position to its view row; if the filter
// hides it, leave the cursor where it is.
if let Some(view) = self.filter.to_view(self.current_position) {
self.select(Some(view));
}
self.select(Some(self.current_position));
}
/// The real queue positions an action applies to: every marked row, or
/// the cursor row when nothing is marked. Positions are read off the
/// **current** list, so they always match the newest snapshot
/// (architecture/queue-register.md D5).
fn action_positions(&self) -> Vec<usize> {
if self.has_marks() {
return self
.list
.iter()
.enumerate()
.filter(|(_, item)| item.marked)
.map(|(pos, _)| pos)
.collect();
}
self.selected_position().into_iter().collect()
}
/// `d`: remove the marked rows (or the cursor row), handing them to the
/// register first so `p`/`P` can bring them back.
pub fn remove_track(&mut self) -> (Vec<String>, Vec<String>) {
let positions = self.action_positions();
if positions.is_empty() {
return (Vec::new(), Vec::new());
}
let yanked = self.entries_at(&positions);
if self.tx.send(MessageFromUi::RemoveTracks(positions)).is_ok() {
self.remove_marks();
}
yanked
}
/// `y`: put the marked rows (or the cursor row) in the register without
/// removing anything. Consumes the marks, like queueing does.
pub fn yank(&mut self) -> (Vec<String>, Vec<String>) {
let yanked = self.entries_at(&self.action_positions());
if !yanked.0.is_empty() {
self.remove_marks();
}
yanked
}
/// Real positions of the marked rows, in queue order (inspection helper,
/// mirroring the library's `marked_titles`).
pub fn marked_positions(&self) -> Vec<usize> {
self.list
.iter()
.enumerate()
.filter(|(_, item)| item.marked)
.map(|(pos, _)| pos)
.collect()
}
/// Paths and labels of the given real positions, in queue order.
fn entries_at(&self, positions: &[usize]) -> (Vec<String>, Vec<String>) {
let mut paths = Vec::with_capacity(positions.len());
let mut labels = Vec::with_capacity(positions.len());
for pos in positions {
if let Some(item) = self.list.get(*pos) {
paths.push(item.path.clone());
labels.push(item.title.clone());
}
}
(paths, labels)
}
/// Every queue entry, for `c`/`C` to hand to the register before the
/// server drops them. `keep_current` mirrors the RPC's flag.
pub fn all_entries(&self, keep_current: bool) -> (Vec<String>, Vec<String>) {
let positions: Vec<usize> = (0..self.list.len())
.filter(|pos| !(keep_current && *pos == self.current_position))
.collect();
self.entries_at(&positions)
}
/// The insert position for a paste: after the cursor for `p`, at the
/// cursor for `P` (which restores what `d` just removed). An empty queue
/// pastes at the front.
pub fn paste_position(&self, before: bool) -> usize {
match self.selected_position() {
Some(pos) if before => pos,
Some(pos) => pos + 1,
None => 0,
pub fn remove_track(&mut self) {
if let Some(pos) = self.selected() {
// FIXME: mark multiple tracks on queue and remove them
self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
}
}
pub fn update_position(&mut self, pos: usize) {
@ -207,108 +55,43 @@ impl Queue {
}
pub fn update_queue(&mut self, queue: QueueData) {
self.current_position = queue.current_position as usize;
self.resolving = queue.resolving;
// The queue is server-pushed and rebuilt on every change, so marks
// are carried across by matching track paths rather than indices —
// otherwise a mark would silently retarget when playback advances
// (architecture/queue-register.md D5).
let old_paths: Vec<String> = self.list.iter().map(|i| i.path.clone()).collect();
let old_marked: Vec<bool> = self.list.iter().map(|i| i.marked).collect();
let new_paths: Vec<String> = queue.tracks.iter().map(|t| t.path.clone()).collect();
let carried = carry_marks(&old_paths, &old_marked, &new_paths);
self.list = queue
.tracks
.iter()
.enumerate()
.map(|(idx, t)| UiItem {
path: t.path.clone(),
.map(|(i, t)| UiItem {
uuid: t.uuid.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: carried.get(idx).copied().unwrap_or(false),
marked: false,
is_queable: false,
is_creatable: false,
is_editable: false,
is_deletable: false,
is_downloadable: false,
is_skipped: t.is_skipped,
is_captured: t.is_captured,
})
.collect();
// The queue is re-sent often (position ticks, resolving); keep
// any active search and just recompute which rows it matches.
self.filter
.recompute(self.list.iter().map(|i| i.title.as_str()));
self.update_selection();
}
/// Draws the pane. `register_len` is the number of entries `p`/`P` would
/// paste; a non-zero count is shown in the title so a paste is never
/// blind.
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool, register_len: usize) {
let selected = self.list_state.selected();
// Render only the visible rows; `view` is the rendered index the
// selection bar keys off, `real` the queue position (which drives
// the playing marker).
let mut queue_items: Vec<ListItem> = self
.filter
.visible()
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
let queue_items: Vec<ListItem> = self
.list
.iter()
.map(|&real| (real, &self.list[real]))
.enumerate()
.map(|(idx, (real, item))| {
let active = real == self.current_position;
.map(|(idx, item)| {
let active = idx == self.current_position;
// Markers, in the library's vocabulary: `>` is the playing
// track, `*` is a mark (`s`, or painted in visual mode). A
// row can be both.
let mut title = String::new();
if active {
title.push_str("> ");
}
if item.marked {
title.push_str("* ");
}
title.push_str(&item.title);
let mut style = if active {
let title = if active {
format!("> {}", item.title)
} else {
item.title.to_string()
};
let style = if active {
Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD)
} else if item.marked {
// Same green as a marked library row.
Style::default()
.fg(COLOR_GREEN)
.add_modifier(Modifier::BOLD)
} else if item.is_skipped {
// No playable audio: rendered red (not bold — the
// playing marker keeps precedence), skipped by
// playback (architecture/incremental-captures.md).
Style::default().fg(COLOR_RED)
} else {
Style::default()
};
// A colored foreground is unreadable on the light focused
// selection bar — switch it to the dark tone there (D7).
if focused && selected == Some(idx) && style.fg.is_some() {
style = style.fg(COLOR_PRIMARY_DARK);
}
ListItem::new(Span::from(title)).style(style)
})
.collect();
if self.resolving {
// Render-time pseudo-item: more tracks are on their way. It is
// not part of `self.list`, so it can never be selected or
// removed. The render loop redraws at least every 100 ms,
// which keeps the dots moving.
static RENDERED_FIRST_AT: std::sync::OnceLock<std::time::Instant> =
std::sync::OnceLock::new();
let elapsed = RENDERED_FIRST_AT
.get_or_init(std::time::Instant::now)
.elapsed()
.as_millis();
queue_items.push(
ListItem::new(Span::from(Self::loading_dots(elapsed)))
.style(Style::default().fg(COLOR_SECONDARY)),
);
}
let queue_list = List::new(queue_items)
.block(
@ -320,19 +103,7 @@ impl Queue {
} else {
COLOR_PRIMARY_DARK
}))
// One title, several claimants: a mode the user is *in*
// (visual, an active search) outranks a result they have
// already been shown, which outranks the standing register
// count (architecture/queue-order.md D15).
.title(match (self.visual.is_some(), self.filter.query()) {
(true, _) => "Queue — VISUAL".to_string(),
(false, Some(query)) => format!("Queue — /{query}"),
(false, None) => match (self.notice(), register_len) {
(Some(notice), _) => format!("Queue — {notice}"),
(None, 0) => "Queue".to_string(),
(None, n) => format!("Queue — register: {n}"),
},
}),
.title("Queue"),
)
.highlight_style(Style::default().bg(if focused {
COLOR_PRIMARY
@ -346,8 +117,7 @@ impl Queue {
impl StatefulList for Queue {
fn get_size(&self) -> usize {
// Navigation operates on the filtered (visible) view.
self.filter.view_len()
self.list.len()
}
fn select(&mut self, idx: Option<usize>) {
@ -358,315 +128,3 @@ impl StatefulList for Queue {
self.list_state.selected()
}
}
impl MarkedPane for Queue {
fn items(&self) -> &[UiItem] {
&self.list
}
fn items_mut(&mut self) -> &mut [UiItem] {
&mut self.list
}
fn filter(&self) -> &Filter {
&self.filter
}
fn visual(&self) -> Option<usize> {
self.visual
}
fn set_visual(&mut self, anchor: Option<usize>) {
self.visual = anchor;
}
fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Every queue row is a track, so every row may be marked — the
/// library's `is_queable` gate does not apply here (queue rows carry
/// `is_queable: false`).
fn markable(&self, _item: &UiItem) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::Track;
use ratatui::{backend::TestBackend, Terminal};
fn queue_data(titles: &[&str], resolving: bool) -> QueueData {
QueueData {
timestamp: 0,
current_position: 0,
tracks: titles
.iter()
.map(|t| Track {
path: format!("/tidal/x/{t}"),
artist: "artist".to_string(),
title: t.to_string(),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
})
.collect(),
resolving,
}
}
fn rendered_rows(queue: &mut Queue) -> Vec<String> {
let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| queue.render(f, f.area(), true, 0))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
/// The row content inside the borders, trimmed.
fn inner_rows(queue: &mut Queue) -> Vec<String> {
rendered_rows(queue)
.iter()
.skip(1)
.map(|row| row.trim_matches(['│', ' ']).to_string())
.collect()
}
fn dots_row_count(rows: &[String]) -> usize {
rows.iter()
.filter(|row| !row.is_empty() && row.chars().all(|c| c == '.'))
.count()
}
#[test]
fn resolving_queue_renders_trailing_dots_item() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], true));
let rows = inner_rows(&mut queue);
assert_eq!(dots_row_count(&rows), 1, "rows: {rows:?}");
// The dots trail the tracks: they come after the last track row.
let last_track = rows.iter().position(|r| r.contains("two")).unwrap();
let dots = rows
.iter()
.position(|r| !r.is_empty() && r.chars().all(|c| c == '.'))
.unwrap();
assert!(last_track < dots, "rows: {rows:?}");
}
#[test]
fn settled_queue_has_no_dots_item() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
let rows = inner_rows(&mut queue);
assert_eq!(dots_row_count(&rows), 0, "rows: {rows:?}");
}
#[test]
fn resolving_flag_clears_with_the_next_update() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one"], true));
queue.update_queue(queue_data(&["one", "two"], false));
let rows = inner_rows(&mut queue);
assert_eq!(dots_row_count(&rows), 0, "rows: {rows:?}");
}
#[test]
fn dots_item_is_outside_the_selectable_list() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], true));
// Selection, removal and navigation all key off get_size; the
// pseudo-item must not be reachable through any of them.
assert_eq!(queue.get_size(), 2);
}
#[test]
fn loading_dots_cycle_one_to_three() {
assert_eq!(Queue::loading_dots(0), ".");
assert_eq!(Queue::loading_dots(400), "..");
assert_eq!(Queue::loading_dots(800), "...");
assert_eq!(Queue::loading_dots(1200), ".");
}
/// Renders and returns the buffer plus the y of the row containing
/// `needle` and the x of its first character.
#[test]
fn marked_rows_render_a_star_like_the_library() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
queue.select(Some(1));
queue.toggle_mark();
let rows = rendered_rows(&mut queue);
// The playing row keeps `>`; the marked row gets `*`.
assert!(
rows.iter().any(|row| row.contains("* artist - two")),
"marked row needs a star: {rows:?}"
);
// And the title says so while visual mode is on.
queue.toggle_visual();
assert!(
rendered_rows(&mut queue)
.iter()
.any(|row| row.contains("VISUAL")),
"visual mode needs a title indicator"
);
}
fn render_and_find(queue: &mut Queue, needle: &str) -> (ratatui::buffer::Buffer, u16, u16) {
let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| queue.render(f, f.area(), true, 0))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
for y in 0..buffer.area.height {
let row: String = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect();
if let Some(col) = row.find(needle) {
return (buffer, col as u16, y);
}
}
panic!("row containing {needle:?} not found");
}
#[test]
fn skipped_tracks_render_red() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
let mut data = queue_data(&["one", "two"], false);
data.tracks[1].is_skipped = true;
queue.update_queue(data);
// Selection sits on row 0; the unselected skipped row is red.
let (buffer, x, y) = render_and_find(&mut queue, "artist - two");
assert_eq!(
buffer[(x, y)].style().fg,
Some(super::COLOR_RED),
"skipped tracks must be red"
);
// The playing track keeps its red marker when not under the bar.
queue.select(Some(1));
let (buffer, x, y) = render_and_find(&mut queue, "> artist - one");
assert_eq!(buffer[(x, y)].style().fg, Some(super::COLOR_RED));
}
#[test]
fn filtering_narrows_the_view_and_maps_removal_to_the_real_position() {
let (tx, rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
// "gam" matches only the third track (real position 2).
queue.set_filter(Some("gam".to_string()));
assert_eq!(queue.get_size(), 1, "one visible row");
// The single visible row is view index 0; removing it must send
// the *real* queue position, not the view index.
queue.select(Some(0));
queue.remove_track();
match rx.try_recv() {
Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![2]),
other => panic!("expected RemoveTracks([2]), got {:?}", other.is_ok()),
}
// Clearing the filter restores the full view.
queue.set_filter(None);
assert_eq!(queue.get_size(), 3);
}
#[test]
fn the_filter_survives_queue_updates() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
queue.set_filter(Some("beta".to_string()));
assert_eq!(queue.get_size(), 1);
// A stream re-send (e.g. a position tick) keeps the active search
// and just recomputes which rows match.
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
assert_eq!(queue.get_size(), 1, "search preserved across updates");
assert_eq!(queue.filter_query(), Some("beta"));
}
/// The dedup result is shown in the pane title, and `0` is shown too — it
/// is the answer "no duplicates" (architecture/queue-order.md D15).
#[test]
fn the_dedup_result_appears_in_the_title() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
queue.show_dedup_result(7);
let title = rendered_rows(&mut queue).remove(0);
assert!(title.contains('7'), "title: {title:?}");
// Zero is an answer, not a non-event: it has to say so in words a
// user can act on, not read as a dropped keypress.
queue.show_dedup_result(0);
let title = rendered_rows(&mut queue).remove(0);
assert!(
title.contains("no duplicates"),
"a zero count is still an answer: {title:?}"
);
}
#[test]
fn the_dedup_result_expires() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one"], false));
queue.show_dedup_result(3);
assert!(queue.notice().is_some());
// Backdate the stamp past the linger; the expiry is checked at render
// time, so nothing else has to run.
queue.notice = queue.notice.take().map(|(text, at)| {
(
text,
at - NOTICE_LINGER - std::time::Duration::from_millis(1),
)
});
assert!(queue.notice().is_none());
let title = rendered_rows(&mut queue).remove(0);
assert!(
!title.contains('3'),
"expired notice still shown: {title:?}"
);
}
/// The title has one slot and several claimants; a mode the user is *in*
/// outranks a result they have already seen.
#[test]
fn an_active_search_outranks_the_dedup_result() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta"], false));
queue.set_filter(Some("bet".to_string()));
queue.show_dedup_result(2);
let title = rendered_rows(&mut queue).remove(0);
assert!(title.contains("/bet"), "title: {title:?}");
}
#[test]
fn colored_rows_darken_under_the_focused_selection_bar() {
// A red (skipped) row under the light focused selection bar was
// unreadable; the foreground switches to the dark tone there
// (architecture/incremental-captures.md D7).
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
let mut data = queue_data(&["one", "two"], false);
data.tracks[1].is_skipped = true;
queue.update_queue(data);
queue.select(Some(1));
let (buffer, x, y) = render_and_find(&mut queue, "two");
assert_eq!(
buffer[(x, y)].style().fg,
Some(super::COLOR_PRIMARY_DARK),
"selected colored rows must use the dark foreground"
);
}
}

View File

@ -1,85 +0,0 @@
//! The client-side register: what `y`, `d`, `c`, and `C` put aside and `p`/`P`
//! paste back (architecture/queue-register.md D1D3).
//!
//! One unnamed slot, in memory, overwritten by each write — vim's unnamed
//! register, not a history. It holds library **paths**, so pasting
//! re-resolves them: a yanked node expands to its tracks at paste time, and a
//! path that no longer resolves simply does not come back.
/// Paths set aside by the last yank or delete, with labels for display.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Register {
paths: Vec<String>,
labels: Vec<String>,
}
impl Register {
/// Overwrite the register. Empty `paths` clears it.
pub fn set(&mut self, paths: Vec<String>, labels: Vec<String>) {
self.paths = paths;
self.labels = labels;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
/// What a paste sends, in yank order.
pub fn paths(&self) -> &[String] {
&self.paths
}
/// Row labels, for the status line only — never sent to the server.
pub fn labels(&self) -> &[String] {
&self.labels
}
}
#[cfg(test)]
mod tests {
use super::Register;
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_fresh_register_is_empty() {
let reg = Register::default();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
assert!(reg.paths().is_empty());
}
#[test]
fn set_stores_paths_in_order_with_labels() {
let mut reg = Register::default();
reg.set(v(&["/fs/a", "/fs/b"]), v(&["A", "B"]));
assert!(!reg.is_empty());
assert_eq!(reg.len(), 2);
assert_eq!(reg.paths(), ["/fs/a", "/fs/b"]);
assert_eq!(reg.labels(), ["A", "B"]);
}
/// A write overwrites: one slot, no history (D2).
#[test]
fn a_second_write_replaces_the_first() {
let mut reg = Register::default();
reg.set(v(&["/fs/a"]), v(&["A"]));
reg.set(v(&["/tidal/x", "/tidal/y"]), v(&["X", "Y"]));
assert_eq!(reg.paths(), ["/tidal/x", "/tidal/y"]);
assert_eq!(reg.labels(), ["X", "Y"]);
}
#[test]
fn setting_nothing_clears_it() {
let mut reg = Register::default();
reg.set(v(&["/fs/a"]), v(&["A"]));
reg.set(Vec::new(), Vec::new());
assert!(reg.is_empty());
}
}

View File

@ -1,222 +0,0 @@
//! The queue sort menu: a modal overlay that turns one keypress into a sort
//! strategy (architecture/queue-order.md D14).
//!
//! Opened with `S` in the queue pane. While it is open the bindings table is
//! unreachable, exactly like the help, input and search overlays: keys answer
//! *this* menu. The strategies live in one table so the overlay lists what it
//! accepts and nothing can drift out of it.
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use crabidy_core::proto::crabidy::QueueSort;
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
/// One row of the menu: the key that picks it, what it does, and the wire
/// strategy it maps to.
pub struct SortChoice {
/// Lowercase key; its uppercase form sorts descending (except
/// [`QueueSort::Reverse`], which has no direction).
pub key: char,
/// Shown verbatim in the overlay. Imperative, no trailing period.
pub description: &'static str,
pub sort: QueueSort,
}
/// The menu, in display order. `l` is album — `a` is taken by artist, and a
/// menu key only has to be unambiguous *inside the menu*, where the overlay
/// spells it out.
pub const SORT_MENU: &[SortChoice] = &[
SortChoice {
key: 'a',
description: "Artist, then album",
sort: QueueSort::Artist,
},
SortChoice {
key: 'l',
description: "Album",
sort: QueueSort::Album,
},
SortChoice {
key: 't',
description: "Title",
sort: QueueSort::Title,
},
SortChoice {
key: 'd',
description: "Duration",
sort: QueueSort::Duration,
},
SortChoice {
key: 'r',
description: "Reverse the current order",
sort: QueueSort::Reverse,
},
];
/// The strategy and direction a key picks, or `None` for a key the menu does
/// not claim (the caller then leaves the menu open).
///
/// The uppercase form of a key sorts descending. `QueueSort::Reverse` ignores
/// the direction on the wire (D6), so `R` and `r` are the same request.
pub fn choose(key: char) -> Option<(QueueSort, bool)> {
let lower = key.to_ascii_lowercase();
let entry = SORT_MENU.iter().find(|entry| entry.key == lower)?;
// Reverse has no direction on the wire, so `R` must not send a
// "descending reverse" the server would then ignore (D6).
let descending = key.is_ascii_uppercase() && entry.sort != QueueSort::Reverse;
Some((entry.sort, descending))
}
/// The footer line: how to sort descending, and how to leave.
const FOOTER: &str = "Capitals sort descending · Esc/q/S closes";
/// The menu's lines: a key column, the descriptions, and the footer.
fn lines() -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = SORT_MENU
.iter()
.map(|entry| {
Line::from(vec![
Span::styled(
format!(" {} ", entry.key),
Style::default()
.fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::from(format!(" {}", entry.description)),
])
})
.collect();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
FOOTER,
Style::default().fg(COLOR_SECONDARY),
)));
lines
}
/// Renders the menu as a `Clear`-backed popup centered in the frame, above
/// everything else in it.
pub fn render(f: &mut Frame) {
let area = popup_area(f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(COLOR_PRIMARY))
.title("Sort queue");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(lines()), inner);
}
/// The centered popup rectangle, sized to the menu but clamped to `frame`.
/// Overflowing lines are truncated rather than scrolled — the help modal's
/// rule, and this content is five rows.
fn popup_area(frame: Rect) -> Rect {
let content_width = lines()
.iter()
.map(|line| line.width() as u16)
.max()
.unwrap_or(0)
.max(FOOTER.chars().count() as u16);
let width = content_width.saturating_add(2).min(frame.width);
let height = (lines().len() as u16).saturating_add(2).min(frame.height);
Rect::new(
frame.x + (frame.width.saturating_sub(width)) / 2,
frame.y + (frame.height.saturating_sub(height)) / 2,
width,
height,
)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn a_lowercase_key_sorts_ascending_and_its_capital_descending() {
assert_eq!(choose('a'), Some((QueueSort::Artist, false)));
assert_eq!(choose('A'), Some((QueueSort::Artist, true)));
assert_eq!(choose('l'), Some((QueueSort::Album, false)));
assert_eq!(choose('t'), Some((QueueSort::Title, false)));
assert_eq!(choose('D'), Some((QueueSort::Duration, true)));
}
/// Reverse has no direction on the wire (D6), so both cases are the same
/// request — not a "descending reverse" that would silently be a no-op.
#[test]
fn reverse_ignores_the_case() {
assert_eq!(choose('r'), Some((QueueSort::Reverse, false)));
assert_eq!(choose('R'), Some((QueueSort::Reverse, false)));
}
#[test]
fn keys_the_menu_does_not_offer_are_not_claimed() {
for key in ['x', 'j', '1', ' ', 'q'] {
assert_eq!(choose(key), None, "{key:?} is not a strategy");
}
}
/// Every table row must be reachable by its own key, and no two rows may
/// claim the same one.
#[test]
fn the_table_is_consistent() {
let mut seen = Vec::new();
for entry in SORT_MENU {
assert!(
entry.key.is_ascii_lowercase(),
"{:?} must be a lowercase key so its capital is free for descending",
entry.key
);
assert!(!seen.contains(&entry.key), "duplicate key {:?}", entry.key);
assert!(!entry.description.trim().is_empty());
assert_eq!(choose(entry.key), Some((entry.sort, false)));
seen.push(entry.key);
}
}
#[test]
fn the_overlay_lists_every_strategy_with_its_key() {
let backend = TestBackend::new(60, 16);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(render).expect("draw must not panic");
let buffer = terminal.backend().buffer().clone();
let text: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
for entry in SORT_MENU {
assert!(
text.contains(entry.description),
"{:?} missing from the overlay:\n{text}",
entry.description
);
}
}
/// A frame smaller than the menu must clamp, not panic or draw outside it
/// — the help modal's rule.
#[test]
fn a_tiny_frame_clamps_the_popup() {
for (w, h) in [(80, 24), (20, 6), (4, 2), (1, 1)] {
let frame = Rect::new(0, 0, w, h);
let area = popup_area(frame);
assert!(area.width <= frame.width && area.height <= frame.height);
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(render).expect("draw must not panic");
}
}
}

View File

@ -1,12 +1,4 @@
use std::{
path::{Path, PathBuf},
str::FromStr,
};
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
use ratatui::style::Color;
use crate::app::SpectrumStyle;
#[derive(ClapSerde, Serialize, Debug)]
#[clap(author, version, about)]
@ -16,530 +8,10 @@ pub struct Config {
pub server: ServerConfig,
}
/// The on-disk path of a client config file:
/// `dirs::config_dir()/crabidy/<file_name>`.
pub fn config_path(file_name: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("crabidy").join(file_name))
}
/// Loads a client config, writing a defaults file on first run.
///
/// This is the no-subcommand read path, replacing the general
/// `crabidy_core::init_config` for the clap-derive binaries: it reads the
/// file (or writes defaults when absent) using the same TOML serialization,
/// but never calls `merge_clap` — argv is parsed by `cbd_cli::TuiCli`, and
/// the flags are applied afterwards with [`apply_overrides`]. A missing
/// config directory falls back to the built-in defaults without writing.
pub fn load_first_run(file_name: &str) -> Config {
let Some(path) = config_path(file_name) else {
return Config::default();
};
load_first_run_at(&path)
}
/// [`load_first_run`] against an explicit path (the testable core).
pub fn load_first_run_at(path: &Path) -> Config {
if let Some(parent) = path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
eprintln!(
"could not create config directory {}: {err}",
parent.display()
);
return Config::default();
}
}
if !path.is_file() {
let config = Config::default();
match toml::to_string_pretty(&config) {
Ok(text) => {
if let Err(err) = std::fs::write(path, text) {
eprintln!("could not write config {}: {err}", path.display());
}
}
Err(err) => eprintln!("could not serialize config: {err}"),
}
return config;
}
load_existing(path).unwrap_or_default()
}
/// Reads an existing config file into a [`Config`], returning `None` on a
/// missing/unreadable/unparsable file (the caller falls back to defaults).
fn load_existing(path: &Path) -> Option<Config> {
let text = std::fs::read_to_string(path).ok()?;
match toml::from_str::<<Config as ClapSerde>::Opt>(&text) {
Ok(opt) => Some(Config::from(opt)),
Err(err) => {
eprintln!("invalid config {}: {err}", path.display());
None
}
}
}
/// Applies CLI overrides onto a loaded config: a provided flag wins over the
/// file value; an omitted flag leaves the file value in place
/// (architecture/cli.md D2).
pub fn apply_overrides(
config: &mut Config,
address: Option<String>,
user: Option<String>,
password: Option<String>,
spectrum: Option<bool>,
) {
if let Some(address) = address {
config.server.address = address;
}
if let Some(user) = user {
config.server.user = user;
}
if let Some(password) = password {
config.server.password = password;
}
if let Some(spectrum) = spectrum {
config.server.spectrum = spectrum;
}
}
/// Writes `config` back to `file_name` (same TOML shape as [`load_first_run`]
/// wrote), creating the config directory if missing. Returns the path
/// written. The password is stored in plaintext — keep the file private.
pub fn store(file_name: &str, config: &Config) -> Result<PathBuf, String> {
let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?;
store_at(&path, config)?;
Ok(path)
}
/// [`store`] against an explicit path (the testable core).
pub fn store_at(path: &Path, config: &Config) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
}
let text =
toml::to_string_pretty(config).map_err(|err| format!("cannot serialize config: {err}"))?;
std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display()))
}
/// The `auth` subcommand writer: loads `file_name` (or defaults), sets the
/// basic-auth `user` (role name) and cleartext `password` (and `address`
/// when given), preserving other fields, and writes it back.
pub fn write_auth(
file_name: &str,
user: &str,
password: &str,
address: Option<&str>,
) -> Result<PathBuf, String> {
let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?;
write_auth_at(&path, user, password, address)?;
Ok(path)
}
/// [`write_auth`] against an explicit path (the testable core).
pub fn write_auth_at(
path: &Path,
user: &str,
password: &str,
address: Option<&str>,
) -> Result<(), String> {
let mut config = load_existing(path).unwrap_or_default();
config.server.user = user.to_string();
config.server.password = password.to_string();
if let Some(address) = address {
config.server.address = address.to_string();
}
store_at(path, &config)
}
#[derive(ClapSerde, Serialize, Debug)]
pub struct ServerConfig {
/// Server address
#[default("http://127.0.0.1:50051".to_string())]
#[clap(short, long)]
pub address: String,
/// Role to authenticate as: "owner", "queue-owner" or
/// "queue-appender" (architecture/roles-auth.md). Leave empty
/// against a server without configured auth.
#[default(String::new())]
#[clap(short, long)]
pub user: String,
/// Password for the role. Stored in plaintext — keep the config
/// file private. Never logged.
#[default(String::new())]
#[clap(short, long)]
pub password: String,
/// Show the frequency-spectrum bars under the track progress
/// (architecture/spectrum.md). On by default; set false to hide.
#[default(true)]
#[clap(long)]
pub spectrum: bool,
/// Color of the frequency-spectrum bars: a `#rrggbb` hex triple, one
/// of ratatui's names ("red", "light-blue", …), or a 0-255 index into
/// the terminal palette. Defaults to the red the queue marks the
/// playing track with. An unparsable value falls back to that
/// default with a warning — see [`spectrum_style`].
#[default(crate::app::COLOR_RED_HEX.to_string())]
#[clap(long)]
pub spectrum_color: String,
/// Shade the bars from dim at the floor to bright at the top, instead
/// of one flat color. Only a hex `spectrum_color` can be shaded: a
/// name or palette index has no RGB value of ours to interpolate, so
/// those stay flat whatever this says.
#[default(true)]
#[clap(long)]
pub spectrum_gradient: bool,
/// Color the shading reaches at the top of the bars, in the same three
/// forms as `spectrum_color`. Defaults to the secondary purple;
/// "none" (or "off") shades brightness alone, keeping one hue.
#[default(crate::app::COLOR_SECONDARY_HEX.to_string())]
#[clap(long)]
pub spectrum_top_color: String,
/// Color of the peak-hold shadows trailing above the bars, in the same
/// three forms as `spectrum_color`. Defaults to the primary blue;
/// "none" (or "off") draws no shadows.
#[default(crate::app::COLOR_PRIMARY_HEX.to_string())]
#[clap(long)]
pub spectrum_peak_color: String,
/// Fill the shadow from the bar up to its held peak. Set false for a
/// thin rule at the peak alone, leaving the space below it empty.
#[default(true)]
#[clap(long)]
pub spectrum_peak_fill: bool,
/// Seconds a full-scale shadow takes to fall to the floor, 0.05-60
/// (out of range is clamped with a warning). Higher lingers longer.
#[default(10.0)]
#[clap(long)]
pub spectrum_peak_fall: f32,
/// Least bar width in terminal cells, 1-16 (out of range is clamped
/// with a warning). Bars take any spare columns beyond this, so raise
/// it only to force wider bars at the cost of showing fewer bands.
#[default(1)]
#[clap(long)]
pub spectrum_bar_width: usize,
/// Width of the seam dividing two bars, in cells, 0-8 (out of range is
/// clamped with a warning). The seam is the bar itself dimmed, so the
/// bars stay one connected field; 0 removes it.
#[default(1)]
#[clap(long)]
pub spectrum_bar_gap: usize,
/// Height of the line dividing two value rows, in eighths of a row,
/// 0-4 (out of range is clamped with a warning). This is what makes the
/// individual segments of a bar visible; 0 stacks them solid.
#[default(1)]
#[clap(long)]
pub spectrum_row_gap: usize,
}
/// Resolves the spectrum options to a [`SpectrumStyle`].
///
/// A config file is user input: a typo must not take the client down, so an
/// unparsable color is reported on stderr and the default is used. The
/// terminal decides what a name or palette index looks like, so this cannot
/// tell a working color from an invisible one — only a syntactic one from a
/// nonsense one.
pub fn spectrum_style(config: &Config) -> SpectrumStyle {
SpectrumStyle {
color: color_or_default(
&config.server.spectrum_color,
"spectrum_color",
crate::app::COLOR_RED,
),
top: optional_color(
&config.server.spectrum_top_color,
"spectrum_top_color",
crate::app::COLOR_SECONDARY,
),
gradient: config.server.spectrum_gradient,
peak: optional_color(
&config.server.spectrum_peak_color,
"spectrum_peak_color",
crate::app::COLOR_PRIMARY,
),
peak_fill: config.server.spectrum_peak_fill,
peak_fall: seconds(
config.server.spectrum_peak_fall,
"spectrum_peak_fall",
0.05,
60.0,
),
bar_width: cells(
config.server.spectrum_bar_width,
"spectrum_bar_width",
1,
16,
),
bar_gap: cells(config.server.spectrum_bar_gap, "spectrum_bar_gap", 0, 8),
row_gap: cells(config.server.spectrum_row_gap, "spectrum_row_gap", 0, 4),
}
}
/// Parses one color option, falling back to `fallback` with a warning. An
/// empty value is a blank in the file rather than a mistake, so it takes
/// the default silently.
fn color_or_default(raw: &str, key: &str, fallback: Color) -> Color {
let raw = raw.trim();
if raw.is_empty() {
return fallback;
}
Color::from_str(raw).unwrap_or_else(|_| {
eprintln!("invalid {key} {raw:?}, using the default");
fallback
})
}
/// Parses a color option that can be switched off, where "none"/"off" mean
/// "leave this out". Neither word is a color, so there is no value they
/// shadow.
fn optional_color(raw: &str, key: &str, fallback: Color) -> Option<Color> {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("none") || trimmed.eq_ignore_ascii_case("off") {
return None;
}
Some(color_or_default(trimmed, key, fallback))
}
/// Clamps a cell-count option into `min..=max`, warning when it was out of
/// range. A width of a thousand cells is a typo, not an instruction, and
/// clamping keeps a typo from emptying the pane.
fn cells(value: usize, key: &str, min: usize, max: usize) -> usize {
if value < min || value > max {
eprintln!("{key} {value} is outside {min}-{max}, clamping");
}
value.clamp(min, max)
}
/// Clamps a duration option into `min..=max` seconds, warning when it was
/// out of range. A TOML float can also be NaN or infinite, which no clamp
/// would fix, so those take `min`.
fn seconds(value: f32, key: &str, min: f32, max: f32) -> f32 {
if !value.is_finite() {
eprintln!("{key} {value} is not a number of seconds, using {min}");
return min;
}
if value < min || value > max {
eprintln!("{key} {value} is outside {min}-{max}, clamping");
}
value.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn first_run_writes_a_defaults_file_and_reloads_it() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
assert!(!path.is_file());
let config = load_first_run_at(&path);
// Defaults written on first run.
assert!(path.is_file());
assert_eq!(config.server.address, "http://127.0.0.1:50051");
assert_eq!(config.server.user, "");
assert!(config.server.spectrum);
// Reloading reads the file (no second write needed).
let reloaded = load_first_run_at(&path);
assert_eq!(reloaded.server.address, config.server.address);
}
#[test]
fn a_provided_flag_overrides_the_file_and_omitted_flags_do_not() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
let mut config = load_first_run_at(&path);
apply_overrides(
&mut config,
Some("http://pi:50051".to_string()),
None,
None,
Some(false),
);
assert_eq!(config.server.address, "http://pi:50051");
// user was omitted: falls back to the file value.
assert_eq!(config.server.user, "");
assert!(!config.server.spectrum);
}
#[test]
fn the_default_spectrum_color_is_the_queue_red() {
// The config default is a string and the renderer wants a `Color`;
// this is what keeps the two spellings of the same red together.
let config = Config::default();
assert_eq!(config.server.spectrum_color, crate::app::COLOR_RED_HEX);
assert_eq!(spectrum_style(&config).color, crate::app::COLOR_RED);
}
#[test]
fn the_default_peak_color_is_the_primary_blue() {
let config = Config::default();
assert_eq!(
config.server.spectrum_peak_color,
crate::app::COLOR_PRIMARY_HEX
);
let style = spectrum_style(&config);
assert_eq!(style.peak, Some(crate::app::COLOR_PRIMARY));
assert!(style.gradient, "bars are shaded out of the box");
}
#[test]
fn a_spectrum_color_is_read_as_hex_name_or_palette_index() {
let mut config = Config::default();
for (raw, expected) in [
("#00ff7f", Color::Rgb(0, 255, 127)),
("light-blue", Color::LightBlue),
("208", Color::Indexed(208)),
// Surrounding whitespace is a config-file slip, not a value.
(" #010203 ", Color::Rgb(1, 2, 3)),
] {
config.server.spectrum_color = raw.to_string();
assert_eq!(spectrum_style(&config).color, expected, "parsing {raw:?}");
// The peak color takes the same three forms.
config.server.spectrum_peak_color = raw.to_string();
assert_eq!(spectrum_style(&config).peak, Some(expected));
}
}
#[test]
fn an_unusable_spectrum_color_falls_back_to_the_default() {
// A config file is user input: a typo warns and keeps the client
// running rather than taking it down.
let mut config = Config::default();
for raw in ["", " ", "puce", "#12345", "300", "#"] {
config.server.spectrum_color = raw.to_string();
config.server.spectrum_peak_color = raw.to_string();
let style = spectrum_style(&config);
assert_eq!(style.color, crate::app::COLOR_RED, "rejecting {raw:?}");
assert_eq!(
style.peak,
Some(crate::app::COLOR_PRIMARY),
"rejecting {raw:?}"
);
}
}
#[test]
fn a_peak_color_of_none_switches_the_markers_off() {
// "none" and "off" are not colors, so neither shadows a real value.
let mut config = Config::default();
for raw in ["none", "NONE", " off ", "Off"] {
config.server.spectrum_peak_color = raw.to_string();
assert_eq!(spectrum_style(&config).peak, None, "for {raw:?}");
}
}
#[test]
fn the_default_top_color_is_the_secondary_purple() {
let config = Config::default();
assert_eq!(
config.server.spectrum_top_color,
crate::app::COLOR_SECONDARY_HEX
);
assert_eq!(
spectrum_style(&config).top,
Some(crate::app::COLOR_SECONDARY)
);
}
#[test]
fn a_top_color_of_none_shades_brightness_alone() {
let mut config = Config::default();
for raw in ["none", "OFF"] {
config.server.spectrum_top_color = raw.to_string();
assert_eq!(spectrum_style(&config).top, None, "for {raw:?}");
}
}
#[test]
fn the_default_layout_is_connected_bars_with_thin_dividers() {
let style = spectrum_style(&Config::default());
assert_eq!(style.bar_width, 1, "bars take the spare columns");
assert_eq!(style.bar_gap, 1, "a one-cell seam");
assert_eq!(style.row_gap, 1, "an eighth of a row between segments");
assert!(style.peak_fill, "shadows filled");
assert_eq!(style.peak_fall, 10.0, "ten seconds to the floor");
assert_eq!(
style,
crate::app::SpectrumStyle::default(),
"the config defaults and the renderer's own must agree"
);
}
#[test]
fn the_widths_and_the_fall_are_clamped_not_obeyed() {
// A config file is user input: a nonsense width must not empty the
// pane, and a nonsense fall time must not freeze or divide by zero.
let mut config = Config::default();
config.server.spectrum_bar_width = 0;
config.server.spectrum_bar_gap = 999;
config.server.spectrum_row_gap = 8;
config.server.spectrum_peak_fall = 0.0;
let style = spectrum_style(&config);
assert_eq!(style.bar_width, 1);
assert_eq!(style.bar_gap, 8);
assert_eq!(style.row_gap, 4, "a whole cell of gap would draw nothing");
assert_eq!(style.peak_fall, 0.05);
// The other end, and a float that is no duration at all.
config.server.spectrum_bar_width = 999;
config.server.spectrum_peak_fall = f32::NAN;
let style = spectrum_style(&config);
assert_eq!(style.bar_width, 16);
assert_eq!(style.peak_fall, 0.05);
config.server.spectrum_peak_fall = 900.0;
assert_eq!(spectrum_style(&config).peak_fall, 60.0);
}
#[test]
fn the_shadow_fill_and_fall_come_from_the_file() {
let mut config = Config::default();
config.server.spectrum_peak_fill = false;
config.server.spectrum_peak_fall = 1.5;
let style = spectrum_style(&config);
assert!(!style.peak_fill);
assert_eq!(style.peak_fall, 1.5);
}
#[test]
fn the_gradient_can_be_switched_off() {
let mut config = Config::default();
config.server.spectrum_gradient = false;
assert!(!spectrum_style(&config).gradient);
}
#[test]
fn write_auth_round_trips_user_password_address() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
// Seed a defaults file, then set credentials.
load_first_run_at(&path);
write_auth_at(&path, "queue-owner", "s3cret", Some("http://pi:50051")).expect("write auth");
let reloaded = load_first_run_at(&path);
assert_eq!(reloaded.server.user, "queue-owner");
assert_eq!(reloaded.server.password, "s3cret");
assert_eq!(reloaded.server.address, "http://pi:50051");
// spectrum (an unrelated field) is preserved at its default.
assert!(reloaded.server.spectrum);
}
#[test]
fn write_auth_without_address_keeps_the_existing_one() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
write_auth_at(&path, "owner", "pw", Some("http://a:1")).expect("first");
write_auth_at(&path, "owner", "pw2", None).expect("second");
let reloaded = load_first_run_at(&path);
assert_eq!(reloaded.server.password, "pw2");
assert_eq!(reloaded.server.address, "http://a:1");
}
}

View File

@ -1,431 +0,0 @@
//! The cbd-tui client as a library: the server-facing orchestration loop
//! and the terminal UI loop, exposed as [`run`] so both the standalone
//! `cbd-tui` binary and the bundled `cbd` binary can host them
//! (architecture/cbd-bundle.md D1). Tracing setup stays with the
//! binaries — where logs go is a hosting decision.
pub mod app;
pub mod config;
pub mod rpc;
pub mod stderr;
#[cfg(feature = "mpris")]
pub mod mpris;
/// Built without the `mpris` feature: the same shape, doing nothing, so the
/// orchestrator needs no `cfg` of its own (architecture/build-features.md D1).
#[cfg(not(feature = "mpris"))]
pub mod mpris {
use crabidy_core::proto::crabidy::{
get_update_stream_response::Update as StreamUpdate, InitResponse,
};
use flume::Sender;
use crate::app::MessageFromUi;
/// There is no player to feed. The orchestrator holds an `Option` of this
/// and never gets a `Some`.
#[derive(Debug)]
pub struct Feed;
impl Feed {
pub fn publish(&self, _update: &StreamUpdate) {}
pub fn publish_init(&self, _init: &InitResponse) {}
}
pub async fn start(_commands: Sender<MessageFromUi>) -> Option<Feed> {
None
}
}
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use flume::{Receiver, Sender};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::select;
use tokio_stream::StreamExt;
use app::{bindings, App, DispatchResult, MessageFromUi, MessageToUi};
use config::Config;
use rpc::RpcClient;
use tracing::{error, info, warn};
/// Runs the client: the rpc orchestration loop on the runtime, the
/// blocking terminal UI on its own thread. Returns when the user quits
/// the UI.
pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
// The MPRIS player commands the server through the same channel the
// keybindings use, so it is just another producer of `MessageFromUi`
// (architecture/mpris.md D1).
let commands = ui_tx.clone();
// FIXME: unwrap
tokio::spawn(async move { orchestrate(config, (tx, rx), commands).await.unwrap() });
let spectrum_enabled = config.server.spectrum;
// Resolved here rather than in the UI thread so a bad config string is
// reported on stderr before the alternate screen swallows it.
let spectrum_style = config::spectrum_style(config);
tokio::task::spawn_blocking(move || {
run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_style);
})
.await?;
Ok(())
}
async fn orchestrate(
config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
commands: Sender<MessageFromUi>,
) -> Result<(), Box<dyn Error>> {
info!(address = config.server.address, "connecting to server");
let mut rpc_client = rpc::RpcClient::connect(&config.server).await?;
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
if tx
.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))
.is_err()
{
info!("the ui closed before the library root arrived");
return Ok(());
}
}
// A desktop that cannot host the player (no session bus) leaves this
// `None` and changes nothing else (architecture/mpris.md D13).
let mpris = mpris::start(commands).await;
let init_data = rpc_client.init().await?;
info!("received initial state from server");
if let Some(mpris) = &mpris {
mpris.publish_init(&init_data);
}
if tx.send_async(MessageToUi::Init(init_data)).await.is_err() {
info!("the ui closed before the initial state arrived");
return Ok(());
}
loop {
match poll(&mut rpc_client, &rx, &tx, &mpris).await {
Ok(Flow::Continue) => {}
// The UI thread owns the other end; quitting drops it. That is a
// shutdown, not a failed request — reporting it as one produced an
// ERROR line ("sending on a closed channel") on every clean exit,
// and the loop then spun on a stream nobody was listening to.
Ok(Flow::UiGone) => {
info!("the ui closed, stopping the orchestrator");
return Ok(());
}
Err(err) => error!("request to server failed: {err}"),
}
}
}
/// What the orchestrator does after one poll.
enum Flow {
Continue,
/// The `MessageToUi` receiver is gone: the terminal UI has exited.
UiGone,
}
async fn poll(
rpc_client: &mut RpcClient,
rx: &Receiver<MessageFromUi>,
tx: &Sender<MessageToUi>,
mpris: &Option<mpris::Feed>,
) -> Result<Flow, Box<dyn Error>> {
select! {
Ok(msg) = &mut rx.recv_async() => {
match msg {
MessageFromUi::GetLibraryNode(path) => {
if let Some(node) = rpc_client.get_library_node(&path).await? {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
}
},
MessageFromUi::CreateNode { parent_path, title } => {
// Navigates the library into the created node on
// success; on failure the library stays where it is.
match rpc_client.create_library_node(&parent_path, &title).await {
Ok(node) => {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
}
Err(err) => {
error!(parent_path, title, "failed to create node: {err}");
}
}
},
MessageFromUi::RenameNode { path, new_title } => {
// Navigates the library into the renamed node on success;
// on failure the library stays where it is.
match rpc_client.rename_library_node(&path, &new_title).await {
Ok(node) => {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
}
Err(err) => {
error!(path, new_title, "failed to rename node: {err}");
}
}
},
MessageFromUi::DeleteNode { path } => {
// Shows the refreshed parent listing on success; on
// failure the library stays where it is.
match rpc_client.delete_library_node(&path).await {
Ok(parent) => {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(parent.clone()));
}
Err(err) => {
error!(path, "failed to delete node: {err}");
}
}
},
MessageFromUi::AppendTracks(uuids) => {
rpc_client.append_tracks(uuids).await?
}
MessageFromUi::QueueTracks(uuids) => {
rpc_client.queue_tracks(uuids).await?
}
MessageFromUi::InsertTracks(uuids, pos) => {
rpc_client.insert_tracks(uuids, pos).await?
}
MessageFromUi::RemoveTracks(positions) => {
rpc_client.remove_tracks(positions).await?
}
MessageFromUi::ReplaceQueue(uuids) => {
rpc_client.replace_queue(uuids).await?
}
MessageFromUi::NextTrack => {
rpc_client.next_track().await?
}
MessageFromUi::PrevTrack => {
rpc_client.prev_track().await?
}
MessageFromUi::RestartTrack => {
rpc_client.restart_track().await?
}
MessageFromUi::Seek(delta_millis) => {
rpc_client.seek(delta_millis).await?
}
MessageFromUi::SetCurrentTrack(pos) => {
rpc_client.set_current_track(pos).await?
}
MessageFromUi::TogglePlay => {
rpc_client.toggle_play().await?
}
MessageFromUi::Stop => {
rpc_client.stop().await?
}
MessageFromUi::ChangeVolume(delta) => {
rpc_client.change_volume(delta).await?
}
MessageFromUi::ToggleMute => {
rpc_client.toggle_mute().await?
}
MessageFromUi::ToggleShuffle => {
rpc_client.toggle_shuffle().await?
}
MessageFromUi::ToggleRepeat => {
rpc_client.toggle_repeat().await?
}
MessageFromUi::ClearQueue(exclude_current) => {
rpc_client.clear_queue(exclude_current).await?
}
MessageFromUi::DedupQueue { by_title } => {
// The count is the whole point (architecture/queue-order.md
// D2); a failure is logged and the queue simply stays as it
// is — it must not tear down the poll loop.
match rpc_client.dedup_queue(by_title).await {
Ok(removed) => {
let _ = tx.send(MessageToUi::QueueDeduped { removed });
}
Err(err) => error!("failed to dedup the queue: {err}"),
}
}
MessageFromUi::SortQueue { sort, descending } => {
if let Err(err) = rpc_client.sort_queue(sort, descending).await {
error!(?sort, descending, "failed to sort the queue: {err}");
}
}
MessageFromUi::SaveQueue(name) => {
// A rejected save (bad name, empty queue) must not tear
// down the poll loop; the server logs the cause.
if let Err(err) = rpc_client.save_queue(name.clone()).await {
error!(name, "failed to save queue: {err}");
}
}
MessageFromUi::CaptureNode { path, name, download } => {
// A rejected capture (bad name, over-cap subtree, failed
// download) must not tear down the poll loop either.
if let Err(err) = rpc_client
.capture_library_node(path.clone(), name.clone(), download)
.await
{
error!(path, name, download, "failed to capture subtree: {err}");
}
}
}
}
Some(resp) = rpc_client.update_stream.next() => {
match resp {
Ok(resp) => {
if let Some(update) = resp.update {
// The UI thread and the MPRIS player are peers on one
// stream; neither learns anything the other does not.
if let Some(mpris) = mpris {
mpris.publish(&update);
}
if tx.send_async(MessageToUi::Update(update)).await.is_err() {
return Ok(Flow::UiGone);
}
}
}
Err(err) => {
warn!("update stream broke, reconnecting: {err}");
rpc_client.reconnect_update_stream().await;
info!("update stream reconnected");
}
}
}
}
Ok(Flow::Continue)
}
fn run_ui(
tx: Sender<MessageFromUi>,
rx: Receiver<MessageToUi>,
spectrum_enabled: bool,
spectrum_style: app::SpectrumStyle,
) {
// setup terminal
enable_raw_mode().unwrap();
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).unwrap();
// create app and run it
let mut app = App::new(tx);
app.now_playing.set_spectrum_enabled(spectrum_enabled);
app.now_playing.set_spectrum_style(spectrum_style);
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
loop {
for message in rx.try_iter() {
match message {
MessageToUi::ReplaceLibraryNode(node) => {
app.library.update(node);
}
MessageToUi::Init(init_data) => {
if let Some(queue) = init_data.queue {
app.queue.update_queue(queue);
}
if let Some(track) = init_data.queue_track {
app.now_playing.update_track(track.track);
app.queue.update_position(track.queue_position as usize);
}
if let Ok(ps) = PlayState::try_from(init_data.play_state) {
app.now_playing.update_play_state(ps);
}
if let Some(mods) = init_data.mods {
app.now_playing.update_modifiers(&mods);
}
// Both were dropped here, so the pane showed a default
// volume and an unmuted server until the user first
// touched either — a display that starts out wrong.
app.now_playing.update_volume(init_data.volume);
app.now_playing.update_mute(init_data.mute);
}
MessageToUi::Update(update) => match update {
StreamUpdate::Queue(queue) => {
app.queue.update_queue(queue);
}
StreamUpdate::QueueTrack(track) => {
app.now_playing.update_track(track.track);
app.queue.update_position(track.queue_position as usize);
}
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
StreamUpdate::PlayState(play_state) => {
if let Ok(ps) = PlayState::try_from(play_state) {
app.now_playing.update_play_state(ps);
}
}
StreamUpdate::Mods(mods) => {
app.now_playing.update_modifiers(&mods);
}
StreamUpdate::Mute(muted) => app.now_playing.update_mute(muted),
StreamUpdate::Volume(volume) => app.now_playing.update_volume(volume),
StreamUpdate::CaptureProgress(progress) => {
app.captures.apply(progress);
}
StreamUpdate::Spectrum(frame) => {
app.now_playing.update_spectrum(frame.bins);
}
},
MessageToUi::QueueDeduped { removed } => {
app.queue.show_dedup_result(removed);
}
}
}
if let Err(err) = terminal.draw(|f| app.render(f)) {
error!("failed to draw frame: {err}");
break;
}
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout).unwrap() {
if let Event::Key(key) = event::read().unwrap() {
if key.kind == KeyEventKind::Press {
// The overlays are strictly modal: while one is open,
// keys answer it and the bindings table (including
// quit) is unreachable.
if app.search.is_some() {
app.handle_search_key(key);
} else if app.input.is_some() {
app.handle_input_key(key);
} else if app.sort_menu {
app.handle_sort_key(key);
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
if app.dispatch(action) == DispatchResult::Quit {
break;
}
}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
// restore terminal
disable_raw_mode().unwrap();
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.unwrap();
terminal.show_cursor().unwrap();
}

View File

@ -1,138 +1,341 @@
//! The standalone TUI binary: a clap-derive CLI ([`cbd_cli::TuiCli`]). With
//! no subcommand it loads the client config (writing defaults on first run),
//! applies the `--address/--user/--password/--spectrum` overrides, and runs
//! the TUI — exactly as before. Subcommands cover `auth` (write credentials
//! into the config), remote control (`library`/`queue`/`global`), and shell
//! completions. All client logic lives in the library so the bundled `cbd`
//! binary can host it too (architecture/cbd-bundle.md D1).
mod app;
mod config;
mod rpc;
use std::sync::OnceLock;
use std::{
error::Error,
io,
sync::OnceLock,
time::{Duration, Instant},
};
use cbd_cli::{RemoteCmd, TuiCli, TuiCommand};
use cbd_tui::config::{self, Config};
use clap::{CommandFactory, Parser};
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
/// The config file name for the standalone TUI.
const CONFIG_FILE: &str = "cbd-tui.toml";
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use flume::{Receiver, Sender};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::select;
use tokio_stream::StreamExt;
use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
use config::Config;
use rpc::RpcClient;
static CONFIG: OnceLock<Config> = OnceLock::new();
/// Logs to a file: the terminal is owned by the TUI, so writing log lines to
/// stdout/stderr would corrupt the interface. The same reason stderr itself is
/// redirected — see [`cbd_tui::stderr`].
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = cbd_tui::stderr::log_dir();
if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"could not create log directory {}: {err}",
log_dir.display()
);
return None;
}
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd-tui.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,cbd_tui=debug,crabidy_core=debug"));
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true),
)
.init();
Some(guard)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = TuiCli::parse();
match cli.command {
// No subcommand: load config, apply overrides, run the TUI.
None => {
let _log_guard = init_tracing();
// Before the alternate screen: from here on, a write to fd 2 that
// did not go through tracing (a panic message, ALSA's underrun
// chatter) would land on the interface.
let stderr_log = cbd_tui::stderr::log_dir().join("cbd-tui.stderr.log");
if let Err(err) = cbd_tui::stderr::capture_into(&stderr_log) {
eprintln!(
"could not redirect stderr to {}: {err}",
stderr_log.display()
);
}
let mut config = config::load_first_run(CONFIG_FILE);
config::apply_overrides(
&mut config,
cli.remote.address,
cli.remote.user,
cli.remote.password,
cli.spectrum,
);
let config = CONFIG.get_or_init(|| config);
cbd_tui::run(config).await
}
// Subcommands are one-shot CLI actions; a failure prints a short
// message and exits non-zero.
Some(command) => {
if let Err(err) = run_command(&cli.remote, command).await {
eprintln!("error: {err}");
std::process::exit(1);
}
Ok(())
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
// FIXME: unwrap
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
tokio::task::spawn_blocking(|| {
run_ui(ui_tx, ui_rx);
})
.await?;
Ok(())
}
async fn orchestrate<'a>(
config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> {
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
if let Some(root_node) = rpc_client.get_library_node("node:/").await? {
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
}
let init_data = rpc_client.init().await?;
tx.send_async(MessageToUi::Init(init_data)).await?;
loop {
if let Err(er) = poll(&mut rpc_client, &rx, &tx).await {
println!("ERROR");
}
}
}
/// Dispatches a TUI subcommand.
async fn run_command(
remote: &cbd_cli::RemoteArgs,
command: TuiCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
TuiCommand::Auth(args) => {
let password = args
.password
.ok_or("missing password (pass it as an argument)")?;
let path = config::write_auth(
CONFIG_FILE,
args.role.user_name(),
&password,
args.address.as_deref(),
)?;
println!(
"wrote credentials for {} to {}",
args.role.user_name(),
path.display()
);
Ok(())
async fn poll(
rpc_client: &mut RpcClient,
rx: &Receiver<MessageFromUi>,
tx: &Sender<MessageToUi>,
) -> Result<(), Box<dyn Error>> {
select! {
Ok(msg) = &mut rx.recv_async() => {
match msg {
MessageFromUi::GetLibraryNode(uuid) => {
if let Some(node) = rpc_client.get_library_node(&uuid).await? {
tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
}
},
MessageFromUi::AppendTracks(uuids) => {
rpc_client.append_tracks(uuids).await?
}
MessageFromUi::QueueTracks(uuids) => {
rpc_client.queue_tracks(uuids).await?
}
MessageFromUi::InsertTracks(uuids, pos) => {
rpc_client.insert_tracks(uuids, pos).await?
}
MessageFromUi::RemoveTracks(positions) => {
rpc_client.remove_tracks(positions).await?
}
MessageFromUi::ReplaceQueue(uuids) => {
rpc_client.replace_queue(uuids).await?
}
MessageFromUi::NextTrack => {
rpc_client.next_track().await?
}
MessageFromUi::PrevTrack => {
rpc_client.prev_track().await?
}
MessageFromUi::RestartTrack => {
rpc_client.restart_track().await?
}
MessageFromUi::SetCurrentTrack(pos) => {
rpc_client.set_current_track(pos).await?
}
MessageFromUi::TogglePlay => {
rpc_client.toggle_play().await?
}
MessageFromUi::ChangeVolume(delta) => {
rpc_client.change_volume(delta).await?
}
MessageFromUi::ToggleMute => {
rpc_client.toggle_mute().await?
}
MessageFromUi::ToggleShuffle => {
rpc_client.toggle_shuffle().await?
}
MessageFromUi::ToggleRepeat => {
rpc_client.toggle_repeat().await?
}
MessageFromUi::ClearQueue(exclude_current) => {
rpc_client.clear_queue(exclude_current).await?
}
}
}
TuiCommand::Library(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
}
TuiCommand::Queue(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
}
TuiCommand::Global(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
}
TuiCommand::Completions(args) => {
cbd_cli::print_completions(args.shell, &mut TuiCli::command(), "cbd-tui");
Ok(())
Some(resp) = rpc_client.update_stream.next() => {
match resp {
Ok(resp) => {
if let Some(update) = resp.update {
tx.send_async(MessageToUi::Update(update)).await?;
}
}
Err(_) => {
rpc_client.reconnect_update_stream().await;
}
}
}
}
Ok(())
}
/// Resolves the connection for a remote command: the CLI flags win over the
/// client config, which supplies the fallback (address and credentials).
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
let config = config::load_first_run(CONFIG_FILE);
cbd_cli::Connection {
address: remote.address.clone().unwrap_or(config.server.address),
user: remote.user.clone().unwrap_or(config.server.user),
password: remote.password.clone().unwrap_or(config.server.password),
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
// setup terminal
enable_raw_mode().unwrap();
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).unwrap();
// create app and run it
let mut app = App::new(tx.clone());
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
loop {
for message in rx.try_iter() {
match message {
MessageToUi::ReplaceLibraryNode(node) => {
app.library.update(node);
}
MessageToUi::Init(init_data) => {
if let Some(queue) = init_data.queue {
app.queue.update_queue(queue);
}
if let Some(track) = init_data.queue_track {
app.now_playing.update_track(track.track);
app.queue.update_position(track.queue_position as usize);
}
if let Some(ps) = PlayState::from_i32(init_data.play_state) {
app.now_playing.update_play_state(ps);
}
if let Some(mods) = init_data.mods {
app.now_playing.update_modifiers(&mods);
}
}
MessageToUi::Update(update) => match update {
StreamUpdate::Queue(queue) => {
app.queue.update_queue(queue);
}
StreamUpdate::QueueTrack(track) => {
app.now_playing.update_track(track.track);
app.queue.update_position(track.queue_position as usize);
}
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
StreamUpdate::PlayState(play_state) => {
if let Some(ps) = PlayState::from_i32(play_state) {
app.now_playing.update_play_state(ps);
}
}
StreamUpdate::Mods(mods) => {
app.now_playing.update_modifiers(&mods);
}
StreamUpdate::Mute(_) => { /* FIXME: implement */ }
StreamUpdate::Volume(_) => { /* FIXME: implement */ }
},
}
}
terminal.draw(|f| app.render(f));
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout).unwrap() {
if let Event::Key(key) = event::read().unwrap() {
if key.kind == KeyEventKind::Press {
match (app.focus, key.modifiers, key.code) {
(_, KeyModifiers::NONE, KeyCode::Char('q')) => {
break;
}
(_, KeyModifiers::NONE, KeyCode::Tab) => app.cycle_active(),
(_, KeyModifiers::NONE, KeyCode::Char(' ')) => {
tx.send(MessageFromUi::TogglePlay);
}
(_, KeyModifiers::NONE, KeyCode::Char('r')) => {
tx.send(MessageFromUi::RestartTrack);
}
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
tx.send(MessageFromUi::ChangeVolume(-0.1));
}
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
tx.send(MessageFromUi::ChangeVolume(0.1));
}
(_, KeyModifiers::NONE, KeyCode::Char('m')) => {
tx.send(MessageFromUi::ToggleMute);
}
(_, KeyModifiers::NONE, KeyCode::Char('z')) => {
tx.send(MessageFromUi::ToggleShuffle);
}
(_, KeyModifiers::NONE, KeyCode::Char('x')) => {
tx.send(MessageFromUi::ToggleRepeat);
}
(_, KeyModifiers::CONTROL, KeyCode::Char('n')) => {
app.queue.play_next();
}
(_, KeyModifiers::CONTROL, KeyCode::Char('p')) => {
app.queue.play_prev();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('g')) => {
app.library.first();
}
(UiFocus::Library, KeyModifiers::SHIFT, KeyCode::Char('G')) => {
app.library.last();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('j')) => {
app.library.next();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('k')) => {
app.library.prev();
}
(UiFocus::Library, KeyModifiers::CONTROL, KeyCode::Char('d')) => {
app.library.down();
}
(UiFocus::Library, KeyModifiers::CONTROL, KeyCode::Char('u')) => {
app.library.up();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('h')) => {
app.library.ascend();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('l')) => {
app.library.dive();
}
(UiFocus::Library, KeyModifiers::SHIFT, KeyCode::Char('L')) => {
app.library.queue_queue();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('a')) => {
app.library.queue_append();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Enter) => {
app.library.queue_replace();
}
(UiFocus::Library, KeyModifiers::NONE, KeyCode::Char('s')) => {
app.library.toggle_mark();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('p')) => {
if let Some(selected) = app.queue.selected() {
app.library.queue_insert(selected);
}
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('g')) => {
app.queue.first();
}
(UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('G')) => {
app.queue.last();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('j')) => {
app.queue.next();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('k')) => {
app.queue.prev();
}
(UiFocus::Queue, KeyModifiers::CONTROL, KeyCode::Char('d')) => {
app.queue.down();
}
(UiFocus::Queue, KeyModifiers::CONTROL, KeyCode::Char('u')) => {
app.queue.up();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('o')) => {
app.queue.select_current();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Enter) => {
app.queue.play_selected();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('d')) => {
app.queue.remove_track();
}
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('c')) => {
tx.send(MessageFromUi::ClearQueue(true));
}
(UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('C')) => {
tx.send(MessageFromUi::ClearQueue(false));
}
_ => {}
}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
// restore terminal
disable_raw_mode().unwrap();
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.unwrap();
terminal.show_cursor().unwrap();
}

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,16 @@
use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest,
PrevRequest, QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
ClearQueueRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse,
InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
RemoveRequest, ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest,
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
};
use std::{collections::HashMap, error::Error, fmt, time::Duration};
use base64::Engine;
use tonic::{
metadata::MetadataValue,
service::{interceptor::InterceptedService, Interceptor},
transport::{Channel, Endpoint},
Request, Status, Streaming,
Request, Streaming,
};
// FIXME: use anyhow + thiserror
@ -34,78 +29,16 @@ impl fmt::Display for RpcClientError {
impl Error for RpcClientError {}
/// Attaches the configured `authorization: Basic …` header to every
/// outgoing request (architecture/roles-auth.md). Without configured
/// credentials it attaches nothing, keeping the zero-config local
/// setup working against an open server. The header value is a secret
/// and never logged.
#[derive(Clone)]
pub struct AuthInterceptor {
header: Option<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
/// `user` empty means "no credentials".
fn new(user: &str, password: &str) -> Result<Self, Box<dyn Error>> {
if user.is_empty() {
return Ok(Self { header: None });
}
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
let header = format!("Basic {encoded}")
.parse()
// The value is base64: this cannot fail on credential
// contents, only on programmer error.
.map_err(|_| "cannot encode credentials header")?;
Ok(Self {
header: Some(header),
})
}
}
impl Interceptor for AuthInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
/// The service client with the auth interceptor baked in.
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
pub struct RpcClient {
library_node_cache: HashMap<String, LibraryNode>,
client: Client,
client: CrabidyServiceClient<Channel>,
pub update_stream: Streaming<GetUpdateStreamResponse>,
}
/// Whether a library listing may be served from the session cache.
///
/// The server-side folder providers mutate behind the client's back —
/// saves and captures appear under `/crabidy` (`w`/`W`), files change on
/// disk under `/fs`, and `/orphans` is recomputed from the store on every
/// visit — so a cached listing turns freshly captured content (or a changed
/// orphan set) invisible until a restart. Their listings are cheap local
/// walks on the server; always refetch them. Remote provider nodes (tidal,
/// youtube) keep the cache that makes back-navigation instant.
fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
!MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
})
}
impl RpcClient {
pub async fn connect(
server: &'static crate::config::ServerConfig,
) -> Result<RpcClient, Box<dyn Error>> {
let endpoint = Endpoint::from_static(&server.address).connect_lazy();
let interceptor = AuthInterceptor::new(&server.user, &server.password)?;
let mut client = CrabidyServiceClient::with_interceptor(endpoint, interceptor);
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
let endpoint = Endpoint::from_static(addr).connect_lazy();
let mut client = CrabidyServiceClient::new(endpoint);
let update_stream = Self::get_update_stream(&mut client).await;
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
@ -117,7 +50,9 @@ impl RpcClient {
})
}
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
async fn get_update_stream(
client: &mut CrabidyServiceClient<Channel>,
) -> Streaming<GetUpdateStreamResponse> {
loop {
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
@ -140,126 +75,45 @@ impl RpcClient {
pub async fn get_library_node(
&mut self,
path: &str,
uuid: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if is_cacheable(path) && self.library_node_cache.contains_key(path) {
return Ok(self.library_node_cache.get(path));
if self.library_node_cache.contains_key(uuid) {
return Ok(self.library_node_cache.get(uuid));
}
let get_library_node_request = Request::new(GetLibraryNodeRequest {
path: path.to_string(),
uuid: uuid.to_string(),
});
let response = self
.client
.get_library_node(get_library_node_request)
.await?;
if let Some(library_node) = response.into_inner().node {
// Non-cacheable nodes are stored too (the return borrows from
// the map) — they are just always refetched above.
self.library_node_cache
.insert(path.to_string(), library_node);
return Ok(self.library_node_cache.get(path));
.insert(uuid.to_string(), library_node);
return Ok(self.library_node_cache.get(uuid));
}
Err(Box::new(RpcClientError::NotFound))
}
/// Creates a child node under a creatable parent and returns it.
///
/// Cache contract: the created node is inserted into
/// `library_node_cache`, and the *parent's* cache entry is evicted —
/// its child listing just changed and would otherwise be served stale
/// when the user ascends back to it.
pub async fn create_library_node(
&mut self,
parent_path: &str,
title: &str,
) -> Result<&LibraryNode, Box<dyn Error>> {
let request = Request::new(CreateLibraryNodeRequest {
parent_path: parent_path.to_string(),
title: title.to_string(),
});
let response = self.client.create_library_node(request).await?;
let Some(node) = response.into_inner().node else {
return Err(Box::new(RpcClientError::NotFound));
};
// The parent's child listing just changed; a cached copy would hide
// the new node when the user ascends back to it.
self.library_node_cache.remove(parent_path);
let path = node.path.clone();
self.library_node_cache.insert(path.clone(), node);
Ok(&self.library_node_cache[&path])
}
/// Renames an editable node and returns it at its (changed) path.
///
/// Cache contract: the old path's entry and the parent's entry are
/// evicted (the parent's child listing changed; the old path is dead),
/// and the renamed node is inserted under its new path.
pub async fn rename_library_node(
&mut self,
path: &str,
new_title: &str,
) -> Result<&LibraryNode, Box<dyn Error>> {
let request = Request::new(RenameLibraryNodeRequest {
path: path.to_string(),
new_title: new_title.to_string(),
});
let response = self.client.rename_library_node(request).await?;
let Some(node) = response.into_inner().node else {
return Err(Box::new(RpcClientError::NotFound));
};
// The old path is dead and the parent's child listing changed;
// cached copies would resurrect the old term.
self.library_node_cache.remove(path);
if let Some(parent) = crabidy_core::parent_path(path) {
self.library_node_cache.remove(parent);
}
let new_path = node.path.clone();
self.library_node_cache.insert(new_path.clone(), node);
Ok(&self.library_node_cache[&new_path])
}
/// Deletes a node and returns the refreshed parent listing.
///
/// Cache contract: the deleted path's entry and the parent's stale entry
/// are evicted, and the returned parent node is inserted fresh.
pub async fn delete_library_node(
&mut self,
path: &str,
) -> Result<&LibraryNode, Box<dyn Error>> {
let request = Request::new(DeleteLibraryNodeRequest {
path: path.to_string(),
});
let response = self.client.delete_library_node(request).await?;
let Some(parent) = response.into_inner().parent else {
return Err(Box::new(RpcClientError::NotFound));
};
// Drop the deleted node and the stale parent listing; the response
// carries the fresh parent to cache instead.
self.library_node_cache.remove(path);
let parent_path = parent.path.clone();
self.library_node_cache.insert(parent_path.clone(), parent);
Ok(&self.library_node_cache[&parent_path])
}
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { paths });
pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { uuids });
self.client.append(append_request).await?;
Ok(())
}
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { paths });
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { uuids });
self.client.queue(queue_request).await?;
Ok(())
}
pub async fn insert_tracks(
&mut self,
paths: Vec<String>,
uuids: Vec<String>,
pos: usize,
) -> Result<(), Box<dyn Error>> {
let insert_request = Request::new(InsertRequest {
paths,
uuids,
position: pos as u32,
});
self.client.insert(insert_request).await?;
@ -280,56 +134,8 @@ impl RpcClient {
Ok(())
}
/// Drops duplicate queue entries and returns how many went
/// (architecture/queue-order.md D2) — the one queue verb with an answer
/// worth showing, because `0` means "no duplicates", not "nothing
/// happened".
pub async fn dedup_queue(&mut self, by_title: bool) -> Result<u32, Box<dyn Error>> {
let response = self
.client
.dedup_queue(Request::new(DedupQueueRequest { by_title }))
.await?;
Ok(response.into_inner().removed)
}
/// Reorders the queue server-side; the new order arrives on the update
/// stream like any other queue change.
pub async fn sort_queue(
&mut self,
sort: QueueSort,
descending: bool,
) -> Result<(), Box<dyn Error>> {
let request = Request::new(SortQueueRequest {
sort: sort as i32,
descending,
});
self.client.sort_queue(request).await?;
Ok(())
}
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
let save_queue_request = Request::new(SaveQueueRequest { name });
self.client.save_queue(save_queue_request).await?;
Ok(())
}
pub async fn capture_library_node(
&mut self,
path: String,
name: String,
download: bool,
) -> Result<(), Box<dyn Error>> {
let capture_request = Request::new(CaptureLibraryNodeRequest {
path,
name,
download,
});
self.client.capture_library_node(capture_request).await?;
Ok(())
}
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { paths });
pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { uuids });
self.client.replace(replace_request).await?;
Ok(())
}
@ -352,17 +158,6 @@ impl RpcClient {
Ok(())
}
/// Moves the playing position by a signed millisecond offset. The server
/// applies it to the live position and clamps it to the track; a source
/// that cannot seek leaves the position untouched.
pub async fn seek(&mut self, delta_millis: i64) -> Result<(), Box<dyn Error>> {
let seek_request = Request::new(SeekRequest {
delta_millis: delta_millis as i32,
});
self.client.seek(seek_request).await?;
Ok(())
}
pub async fn set_current_track(&mut self, pos: usize) -> Result<(), Box<dyn Error>> {
let set_current_request = Request::new(SetCurrentRequest {
position: pos as u32,
@ -377,14 +172,6 @@ impl RpcClient {
Ok(())
}
/// Stops playback (as opposed to pausing it). Sent by the MPRIS `Stop`
/// method; no keybinding reaches it (architecture/mpris.md D5).
pub async fn stop(&mut self) -> Result<(), Box<dyn Error>> {
let stop_request = Request::new(StopRequest {});
self.client.stop(stop_request).await?;
Ok(())
}
pub async fn toggle_shuffle(&mut self) -> Result<(), Box<dyn Error>> {
let toggle_shuffle_request = Request::new(ToggleShuffleRequest {});
self.client.toggle_shuffle(toggle_shuffle_request).await?;
@ -409,54 +196,3 @@ impl RpcClient {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_credentials_no_authorization_header_is_sent() {
let mut interceptor = AuthInterceptor::new("", "ignored").expect("build");
let request = interceptor.call(Request::new(())).expect("intercept");
assert!(request.metadata().get("authorization").is_none());
}
#[test]
fn credentials_become_a_basic_authorization_header() {
let mut interceptor = AuthInterceptor::new("queue-owner", "secret").expect("build");
let request = interceptor.call(Request::new(())).expect("intercept");
let header = request
.metadata()
.get("authorization")
.expect("header attached")
.to_str()
.expect("ascii");
// base64("queue-owner:secret")
assert_eq!(header, "Basic cXVldWUtb3duZXI6c2VjcmV0");
}
#[test]
fn mutable_provider_listings_are_never_cached() {
// Freshly captured/saved content must show up on the next visit
// (a cached /crabidy hid new saves until a TUI restart).
for path in [
"/crabidy",
"/crabidy/faves",
"/crabidy/current",
"/crabidy/road trip",
"/fs/music",
"/orphans",
"/orphans/stray.flac",
] {
assert!(!is_cacheable(path), "{path}");
}
// Remote providers keep instant back-navigation…
for path in ["/", "/tidal", "/tidal/artists/1", "/youtube/search/x"] {
assert!(is_cacheable(path), "{path}");
}
// …and prefix look-alikes are not swept up.
assert!(is_cacheable("/fsdy"));
assert!(is_cacheable("/crabidystore"));
assert!(is_cacheable("/orphansaurus"));
}
}

View File

@ -1,112 +0,0 @@
//! Keeping foreign writes off the TUI's screen.
//!
//! `tracing` goes to a log file precisely because the terminal belongs to the
//! interface — but that only covers what *we* log. File descriptor 2 still
//! points at the terminal, and plenty in this process never goes through
//! `tracing`:
//!
//! - ALSA prints from C (`ALSA lib pcm.c:…: underrun occurred`), which the
//! bundled `cbd` sees because the audio stack shares its process.
//! - the default panic hook, and anything a dependency decides to
//! `eprintln!`.
//!
//! Those bytes land wherever the cursor is, scroll the terminal by a line and
//! leave the whole layout looking shifted — the queue appearing to bleed into
//! the now-playing pane. Because ratatui repaints only the cells that changed,
//! the damage persists until something forces a full redraw. Worse, the
//! message is then *lost*: it never reaches the log, so afterwards there is no
//! record of the underrun that caused it.
//!
//! So the fix is one `dup2`: point fd 2 at a file next to the log before the
//! alternate screen is entered. The diagnostics are kept, just not on screen.
use std::path::{Path, PathBuf};
/// The directory both binaries log into: `crabidy/` under the state dir,
/// falling back to the cache dir and then to the temp dir.
///
/// Shared so the log file and the captured stderr always land together.
pub fn log_dir() -> PathBuf {
dirs::state_dir()
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy")
}
/// Points file descriptor 2 at `path` (appending), so writes that bypass
/// `tracing` are recorded instead of scribbling on the interface.
///
/// Call once, before the terminal is put into raw mode. Errors are returned
/// rather than handled: a client that cannot open its log file should still
/// start — it just keeps the noisy stderr it has always had.
#[cfg(unix)]
pub fn capture_into(path: &Path) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
// `dup2` makes fd 2 a second reference to this file, so dropping `file`
// (and its own descriptor) at the end of this function leaves stderr
// pointing at it.
if unsafe { libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO) } == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// Nothing to do off Unix: there is no `dup2`, and the crabidy clients are
/// terminal programs on Linux and macOS.
#[cfg(not(unix))]
pub fn capture_into(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The redirect must actually move the process's stderr, and appending
/// must not truncate what an earlier run wrote.
///
/// The write goes to the descriptor directly rather than through
/// `eprintln!`: that is what a C library does (the case this exists for),
/// and the test harness intercepts the macro but not the descriptor.
#[cfg(unix)]
#[test]
fn captured_stderr_reaches_the_file_and_appends() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cbd.stderr.log");
std::fs::write(&path, "earlier run\n").expect("seed");
// Keep the real stderr so the rest of the suite still has one. Briefly
// process-wide, so a parallel test writing to fd 2 in this window would
// land in the file too — harmless here, and the alternative is a child
// process for one assertion.
let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
assert!(saved >= 0, "could not save stderr");
capture_into(&path).expect("redirect");
let wrote = std::io::stderr().write_all(b"underrun occurred\n");
let flushed = std::io::stderr().flush();
let restored = unsafe { libc::dup2(saved, libc::STDERR_FILENO) };
assert!(restored >= 0, "could not restore stderr");
unsafe { libc::close(saved) };
wrote.expect("write to the redirected stderr");
flushed.expect("flush");
let captured = std::fs::read_to_string(&path).expect("read back");
assert!(captured.starts_with("earlier run\n"), "{captured:?}");
assert!(captured.contains("underrun occurred"), "{captured:?}");
}
#[test]
fn the_log_dir_is_under_crabidy() {
assert_eq!(
log_dir().file_name().and_then(|n| n.to_str()),
Some("crabidy")
);
}
}

View File

@ -1,145 +0,0 @@
//! The MPRIS player as the desktop sees it: over a real session bus, through
//! a real D-Bus client (architecture/mpris.md).
//!
//! The unit tests in `src/mpris.rs` cover the mapping decisions; this one
//! covers the parts only a bus can answer — that the name is claimed, that
//! the interfaces are served where the spec says, and that a method call
//! becomes a command for the server.
//!
//! Skipped when there is no session bus, which is the normal state of a CI
//! runner. To run it:
//!
//! ```sh
//! devenv shell -- dbus-run-session -- cargo test -p cbd-tui --test mpris_bus
//! ```
#![cfg(feature = "mpris")]
use std::time::Duration;
use cbd_tui::{app::MessageFromUi, mpris};
use crabidy_core::proto::crabidy::{
Album, InitResponse, PlayState, Queue, QueueModifiers, QueueTrack, Track, TrackPosition,
};
use mpris_server::zbus::{zvariant::OwnedValue, Connection, Proxy};
const OBJECT_PATH: &str = "/org/mpris/MediaPlayer2";
const PLAYER_INTERFACE: &str = "org.mpris.MediaPlayer2.Player";
const ROOT_INTERFACE: &str = "org.mpris.MediaPlayer2";
fn playing_state() -> InitResponse {
InitResponse {
queue: Some(Queue {
timestamp: 0,
current_position: 1,
tracks: vec![],
resolving: false,
}),
mods: Some(QueueModifiers {
shuffle: false,
repeat: false,
}),
queue_track: Some(QueueTrack {
queue_position: 1,
track: Some(Track {
path: "/fs/music/song.flac".to_string(),
artist: "the artist".to_string(),
title: "the song".to_string(),
duration: Some(240),
album: Some(Album {
title: "the album".to_string(),
release_date: None,
}),
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}),
}),
play_state: PlayState::Playing.into(),
volume: 0.5,
mute: false,
position: Some(TrackPosition {
position: 12_000,
duration: 240_000,
}),
auth_enabled: false,
}
}
/// Waits for the published mirror to catch up with the feed: the state
/// travels a channel and a task, so a property read straight after a publish
/// may still see the previous value.
async fn await_property(proxy: &Proxy<'_>, name: &str, expected: &str) -> OwnedValue {
for _ in 0..100 {
let value: OwnedValue = proxy.get_property(name).await.expect("read property");
if format!("{value:?}").contains(expected) {
return value;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("{name} never became {expected}");
}
#[tokio::test]
async fn the_desktop_sees_the_track_and_its_keys_reach_the_server() {
if std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() {
eprintln!("no session bus; skipping (see this file's docs)");
return;
}
let (commands_tx, commands) = flume::unbounded();
let feed = mpris::start(commands_tx)
.await
.expect("a session bus is available, so the player must register");
feed.publish_init(&playing_state());
let connection = Connection::session().await.expect("connect to the bus");
let bus_name = format!(
"org.mpris.MediaPlayer2.crabidy.instance{}",
std::process::id()
);
let player = Proxy::new(&connection, bus_name.clone(), OBJECT_PATH, PLAYER_INTERFACE)
.await
.expect("the player interface is served where the spec says");
let root = Proxy::new(&connection, bus_name, OBJECT_PATH, ROOT_INTERFACE)
.await
.expect("the root interface too");
// What a status bar reads.
let identity: String = root.get_property("Identity").await.expect("Identity");
assert_eq!(identity, "crabidy");
await_property(&player, "PlaybackStatus", "Playing").await;
let metadata = await_property(&player, "Metadata", "the song").await;
let metadata = format!("{metadata:?}");
assert!(metadata.contains("the artist"), "{metadata}");
assert!(metadata.contains("the album"), "{metadata}");
assert!(
metadata.contains("/org/crabidy/queue/1"),
"the trackid is the queue position: {metadata}"
);
assert!(
!metadata.contains("/fs/music/song.flac"),
"no library path and no URL may reach the bus: {metadata}"
);
// What a media key does. Pause, because it is the mapping with a
// condition on it: the server only has a toggle.
player
.call::<_, _, ()>("Pause", &())
.await
.expect("Pause is callable");
assert!(
matches!(
commands.recv_timeout(Duration::from_secs(1)),
Ok(MessageFromUi::TogglePlay)
),
"the pause key must reach the server as a playback command"
);
// And a control the desktop is told it does not have.
let can_quit: bool = root.get_property("CanQuit").await.expect("CanQuit");
assert!(!can_quit);
assert!(
root.call::<_, _, ()>("Quit", &()).await.is_err(),
"Quit must be refused, not close the user's terminal"
);
}

1
cbd-web/.gitignore vendored
View File

@ -1 +0,0 @@
/dist

View File

@ -1,36 +0,0 @@
[package]
name = "cbd-web"
version.workspace = true
edition.workspace = true
[dependencies]
crabidy-core.workspace = true
leptos.workspace = true
# The browser-only half: transport, DOM glue, storage. Kept
# target-specific so the native build (which runs the unit tests for
# the pure state/keymap logic) stays free of wasm-only crates.
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook.workspace = true
futures.workspace = true
gloo-timers.workspace = true
tonic = { workspace = true, features = ["codegen"] }
tonic-web-wasm-client.workspace = true
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [
"Document",
# `DomRect` is what `Element::get_bounding_client_rect` returns; the web-sys
# method only exists with the feature on. Click-to-seek needs the gauge's
# geometry to turn a click into a position.
"DomRect",
"Element",
"HtmlInputElement",
"KeyboardEvent",
"Location",
"Performance",
"ScrollIntoViewOptions",
"ScrollLogicalPosition",
"Storage",
"Window",
] }

View File

@ -1,98 +0,0 @@
# cbd-web — the browser client
A [Leptos](https://leptos.dev) client-side WASM app with the same
functionality as `cbd-tui`, served by `crabidy-server` itself.
## How it works
- **Transport**: gRPC-web (`tonic-web-wasm-client`) over the *same*
generated client and proto types the TUI uses (`crabidy-core`). No
second API surface — feature parity is structural. The server wraps
its existing gRPC service in `tonic-web`, so the browser and the TUI
hit identical `/crabidy.v1.CrabidyService/…` paths, and the same role
auth layer gates both.
- **Serving**: the built bundle (`cbd-web/dist`) is embedded into
`crabidy-server` at compile time behind the default-on `web-ui`
feature and served as the fallback route on port 50051. gRPC and
static assets share one origin, so there is no CORS story.
- **Local-first**: pure client-side rendering, every asset in the
bundle (no CDN, no external fonts), library listings cached in memory
like the TUI, credentials and theme in `localStorage`, and the update
stream reconnects with backoff when the server disappears. There is
no CRDT layer — this is a remote control for one live server state,
not an offline-editing app (a deliberate departure from the
`web_client_example_workspace` template that informed the toolchain).
## Functionality
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click), marks
and visual mode (`s`, `v`/`V`) in both panes, the one-slot register (`y`
yanks, `d`/`c`/`C` fill it as they remove, `p`/`P` paste), create/rename/
delete nodes (`%`/`e`/`d`), bookmark and capture (`w`/`W`, with live progress
lines and skipped-track marking), the full queue and playback controls,
seeking (`,`/`.` for 15 seconds, `<`/`>` for a whole track, or a click on the
progress bar), volume, shuffle/repeat, and a `?` help overlay listing the
keys. Keys mirror the TUI; every key also has a clickable control. The `/`
live filter is the one thing that is TUI-only so far.
Two layout details earn their own note:
- `library`/`queue` **tabs** in the top bar switch panes and show which one
the keys go to — what `Tab` does, reachable by thumb.
- Below 700px the panes cannot sit side by side, so **only the focused pane
is rendered**. It is not collapsed to a strip: a strip's truncated rows
still take taps, which sent them to the wrong pane.
A light/dark theme follows the OS and can be toggled (persisted). The accent
color is the crab orange-red.
When the server requires credentials, a login form collects the role
(`owner` / `queue-owner` / `queue-appender`) and password; they are
stored in `localStorage` and sent as the gRPC-web `authorization`
header on every request.
## Building
The WASM toolchain (trunk, wasm-bindgen, the `wasm32-unknown-unknown`
target) is provided by devenv. From the repo root:
```sh
devenv shell -- build-web # release bundle → cbd-web/dist
cargo build -p crabidy-server # embeds cbd-web/dist
```
`build-web` clears `RUSTFLAGS` first: the native toolchain sets the
mold linker, which `rust-lld` (the wasm linker) cannot parse.
Building `crabidy-server` without a `cbd-web/dist` present is fine — it
embeds a placeholder page telling you to run `build-web`. To drop the web
client (and the `tonic-web` layer) entirely, build the server without its
`web-ui` cargo feature — e.g. `--no-default-features --features
all-providers,opus,spectrum`; see `docs/src/build-features.md`.
## Dev loop
Run a server, then a live-reloading trunk server that proxies gRPC-web
to it:
```sh
cargo run -p crabidy-server # or `cbd`
devenv shell -- serve-web # trunk serve on http://127.0.0.1:8080
```
`Trunk.toml` proxies `/crabidy.v1.CrabidyService` to `127.0.0.1:50051`,
so the app behaves as if served from the server.
## Tests
The DOM-free logic (pane/selection state machines, the keymap, capture
progress formatting) lives in `src/state.rs` and `src/keymap.rs` and is
unit-tested on the native target:
```sh
cargo test -p cbd-web
```
Components in `src/app.rs` stay thin over that logic. The server-side
serving and the gRPC-web + auth routing are tested in `crabidy-server`
(`src/web.rs`, `tests/web_server.rs`).

View File

@ -1,14 +0,0 @@
# Build configuration for the wasm bundle (architecture/web-client.md).
# `trunk build --release` writes dist/, which crabidy-server embeds on
# its next build (feature `web-ui`, default on).
[build]
target = "index.html"
release = false
[serve]
# Dev loop: `trunk serve` here + a running crabidy-server; gRPC-web
# calls are proxied to it, everything else is served live-reloading.
[[proxy]]
backend = "http://127.0.0.1:50051"
rewrite = "/crabidy.v1.CrabidyService"

View File

@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>crabidy</title>
<link data-trunk rel="css" href="style.css" />
</head>
<body></body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,651 +0,0 @@
//! Keyboard bindings — the web port of `cbd-tui/src/app/bindings.rs`,
//! keyed by browser `KeyboardEvent` values instead of crossterm codes.
//! Deliberate differences: there is no `q` (quit) in a browser tab, and
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
use crabidy_core::proto::crabidy::QueueSort;
use crate::state::Focus;
/// Everything a key can trigger. Mirrors the TUI's `Action` list; the
/// components translate these into RPCs or local state changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
OpenHelp,
CloseHelp,
CycleFocus,
TogglePlay,
RestartTrack,
VolumeUp,
VolumeDown,
ToggleMute,
ToggleShuffle,
ToggleRepeat,
NextTrack,
PrevTrack,
/// Move the playing position back inside the current track by
/// [`crate::app::SEEK_STEP_MILLIS`]. Near the start it lands at 0 rather
/// than stepping into the previous track (architecture/seek.md A1).
SeekBackward,
/// Move the playing position forward by the same step; overshooting the
/// end lets the track finish, which advances the queue.
SeekForward,
LibraryFirst,
LibraryLast,
LibraryNext,
LibraryPrev,
LibraryJumpDown,
LibraryJumpUp,
LibraryAscend,
LibraryDive,
LibraryToggleMark,
LibraryVisualMode,
LibraryYank,
LibraryCaptureNode,
LibraryDownloadNode,
LibraryCreateNode,
LibraryEditNode,
LibraryDeleteNode,
LibraryQueueAppend,
LibraryQueueNext,
LibraryQueueReplace,
QueueFirst,
QueueLast,
QueueNext,
QueuePrev,
QueueJumpDown,
QueueJumpUp,
QueueSelectCurrent,
QueuePlaySelected,
QueueToggleMark,
QueueVisualMode,
QueueYank,
QueuePaste,
QueuePasteBefore,
QueueRemoveTrack,
QueueClearKeepCurrent,
QueueClearAll,
QueueSaveAs,
/// Drop duplicate queue entries server-side; the count lands in a toast
/// (architecture/queue-order.md D16).
QueueDedup,
/// Drop entries with the same artist and title, keeping the longest take.
/// Aggressive and opt-in (architecture/queue-order.md D3a).
QueueDedupTitles,
/// Open the sort menu dialog.
QueueSortMenu,
/// Close it without sorting (`Escape`/`q`/`S`).
CloseSortMenu,
/// Sort by one strategy — what a sort-menu key or the header's select
/// control dispatches.
QueueSort {
sort: QueueSort,
descending: bool,
},
}
/// One row of the sort menu: the key that picks it and what it does. The same
/// table drives the overlay and the header's select control, so the two can
/// never offer different strategies (architecture/queue-order.md D16).
pub struct SortChoice {
/// Lowercase key; the uppercase form sorts descending, except
/// [`QueueSort::Reverse`], which has no direction.
pub key: char,
pub description: &'static str,
pub sort: QueueSort,
}
/// The sort menu, in display order — the TUI's table, key for key
/// (`cbd-tui/src/app/sort.rs`).
pub const SORT_CHOICES: &[SortChoice] = &[
SortChoice {
key: 'a',
description: "Artist, then album",
sort: QueueSort::Artist,
},
SortChoice {
key: 'l',
description: "Album",
sort: QueueSort::Album,
},
SortChoice {
key: 't',
description: "Title",
sort: QueueSort::Title,
},
SortChoice {
key: 'd',
description: "Duration",
sort: QueueSort::Duration,
},
SortChoice {
key: 'r',
description: "Reverse the current order",
sort: QueueSort::Reverse,
},
];
/// The action a key pressed inside the sort menu maps to: a sort, closing the
/// menu (`Escape`/`q`/`S`), or nothing at all — an unclaimed key leaves the
/// menu open rather than falling through to the pane bindings.
pub fn sort_menu_key(key: &str) -> Option<Action> {
if matches!(key, "Escape" | "q" | "S") {
return Some(Action::CloseSortMenu);
}
let mut chars = key.chars();
let (typed, rest) = (chars.next()?, chars.next());
if rest.is_some() {
// A named key ("Enter", "ArrowDown"): not a strategy.
return None;
}
let choice = SORT_CHOICES
.iter()
.find(|choice| choice.key == typed.to_ascii_lowercase())?;
Some(Action::QueueSort {
sort: choice.sort,
// Reverse has no direction on the wire (D6).
descending: typed.is_ascii_uppercase() && choice.sort != QueueSort::Reverse,
})
}
/// One row of the help overlay: the key label and what it does.
pub struct HelpEntry {
pub scope: &'static str,
pub key: &'static str,
pub description: &'static str,
}
/// The help overlay content, in display order — kept in lockstep with
/// [`lookup`] by the unit tests below.
pub const HELP: &[HelpEntry] = &[
HelpEntry {
scope: "Global",
key: "?",
description: "Show this help",
},
HelpEntry {
scope: "Global",
key: "Tab",
description: "Switch between library and queue",
},
HelpEntry {
scope: "Global",
key: "Space",
description: "Play/pause",
},
HelpEntry {
scope: "Global",
key: "r",
description: "Restart current track",
},
HelpEntry {
scope: "Global",
key: ", / .",
description: "Seek back / forward 15 seconds",
},
HelpEntry {
scope: "Global",
key: "K",
description: "Volume up",
},
HelpEntry {
scope: "Global",
key: "J",
description: "Volume down",
},
HelpEntry {
scope: "Global",
key: "m",
description: "Toggle mute",
},
HelpEntry {
scope: "Global",
key: "z",
description: "Toggle shuffle",
},
HelpEntry {
scope: "Global",
key: "x",
description: "Toggle repeat",
},
HelpEntry {
scope: "Global",
key: "< / >",
description: "Previous / next track",
},
HelpEntry {
scope: "Library",
key: "j / k",
description: "Select next / previous item",
},
HelpEntry {
scope: "Library",
key: "g / G",
description: "Select first / last item",
},
HelpEntry {
scope: "Library",
key: "Ctrl-d / Ctrl-u",
description: "Jump 15 items",
},
HelpEntry {
scope: "Library",
key: "h",
description: "Go to parent folder",
},
HelpEntry {
scope: "Library",
key: "l",
description: "Enter selected folder",
},
HelpEntry {
scope: "Library",
key: "s",
description: "Mark/unmark selection",
},
HelpEntry {
scope: "Library",
key: "v / V",
description: "Visual mode: movement toggles marks",
},
HelpEntry {
scope: "Library",
key: "y",
description: "Yank selection into the register",
},
HelpEntry {
scope: "Library",
key: "w",
description: "Save selection as bookmark",
},
HelpEntry {
scope: "Library",
key: "W",
description: "Download selection as capture (can take long; same name resumes)",
},
HelpEntry {
scope: "Library",
key: "%",
description: "Create node here (e.g. search term)",
},
HelpEntry {
scope: "Library",
key: "e",
description: "Rename selected node (e.g. search term)",
},
HelpEntry {
scope: "Library",
key: "d",
description: "Delete selection (captures ask y/N, and delete files)",
},
HelpEntry {
scope: "Library",
key: "a",
description: "Append selection to queue",
},
HelpEntry {
scope: "Library",
key: "L",
description: "Queue selection after current track",
},
HelpEntry {
scope: "Library",
key: "Enter",
description: "Replace queue with selection",
},
HelpEntry {
scope: "Queue",
key: "j / k",
description: "Select next / previous track",
},
HelpEntry {
scope: "Queue",
key: "g / G",
description: "Select first / last track",
},
HelpEntry {
scope: "Queue",
key: "Ctrl-d / Ctrl-u",
description: "Jump 15 tracks",
},
HelpEntry {
scope: "Queue",
key: "o",
description: "Select the playing track",
},
HelpEntry {
scope: "Queue",
key: "Enter",
description: "Play selected track",
},
HelpEntry {
scope: "Queue",
key: "s",
description: "Mark/unmark selection",
},
HelpEntry {
scope: "Queue",
key: "v / V",
description: "Visual mode: movement toggles marks",
},
HelpEntry {
scope: "Queue",
key: "y",
description: "Yank selection into the register",
},
HelpEntry {
scope: "Queue",
key: "p / P",
description: "Paste the register after / before this track",
},
HelpEntry {
scope: "Queue",
key: "d",
description: "Remove selected track",
},
HelpEntry {
scope: "Queue",
key: "c",
description: "Clear queue except current track",
},
HelpEntry {
scope: "Queue",
key: "C",
description: "Clear entire queue",
},
HelpEntry {
scope: "Queue",
key: "u",
description: "Unique: drop duplicate tracks from the queue",
},
HelpEntry {
scope: "Queue",
key: "U",
description: "Unique by title: one entry per song, keeping the longest",
},
HelpEntry {
scope: "Queue",
key: "S",
description: "Sort the queue (opens a menu of strategies)",
},
HelpEntry {
scope: "Queue",
key: "w",
description: "Save queue under a name",
},
HelpEntry {
scope: "Help",
key: "Esc or ?",
description: "Close help",
},
];
/// Resolves a browser key event to an action, mirroring the TUI's
/// `bindings::lookup`: global chords first, then the focused pane's.
/// `key` is `KeyboardEvent.key` (case carries shift for letters);
/// `ctrl` is `ctrlKey`. While the help overlay is open only its close
/// keys resolve; dialogs bypass this entirely (they are modal).
pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Action> {
if help_open {
return matches!(key, "?" | "Escape" | "q").then_some(Action::CloseHelp);
}
if ctrl {
return match key {
// Mirrors the TUI, where these are the primary track-skip chords.
// `Ctrl-p` resolves and is `prevent_default`ed; `Ctrl-n` is reserved
// by Chrome and Firefox for "new window" above the page and never
// reaches us, which is why `<`/`>` exist.
"n" => Some(Action::NextTrack),
"p" => Some(Action::PrevTrack),
"d" => Some(match focus {
Focus::Library => Action::LibraryJumpDown,
Focus::Queue => Action::QueueJumpDown,
}),
"u" => Some(match focus {
Focus::Library => Action::LibraryJumpUp,
Focus::Queue => Action::QueueJumpUp,
}),
_ => None,
};
}
let global = match key {
"?" => Some(Action::OpenHelp),
"Tab" => Some(Action::CycleFocus),
" " => Some(Action::TogglePlay),
"r" => Some(Action::RestartTrack),
// Two physical keys, all four moves: unshifted seeks 15 seconds,
// shifted skips a track. Plain printable characters, so unlike the
// control chords they collide with nothing the browser reserves.
"," => Some(Action::SeekBackward),
"." => Some(Action::SeekForward),
"<" => Some(Action::PrevTrack),
">" => Some(Action::NextTrack),
"K" => Some(Action::VolumeUp),
"J" => Some(Action::VolumeDown),
"m" => Some(Action::ToggleMute),
"z" => Some(Action::ToggleShuffle),
"x" => Some(Action::ToggleRepeat),
_ => None,
};
if global.is_some() {
return global;
}
match focus {
Focus::Library => match key {
"j" | "ArrowDown" => Some(Action::LibraryNext),
"k" | "ArrowUp" => Some(Action::LibraryPrev),
"g" => Some(Action::LibraryFirst),
"G" => Some(Action::LibraryLast),
"h" | "ArrowLeft" => Some(Action::LibraryAscend),
"l" | "ArrowRight" => Some(Action::LibraryDive),
"s" => Some(Action::LibraryToggleMark),
"v" | "V" => Some(Action::LibraryVisualMode),
"y" => Some(Action::LibraryYank),
"w" => Some(Action::LibraryCaptureNode),
"W" => Some(Action::LibraryDownloadNode),
"%" => Some(Action::LibraryCreateNode),
"e" => Some(Action::LibraryEditNode),
"d" => Some(Action::LibraryDeleteNode),
"a" => Some(Action::LibraryQueueAppend),
"L" => Some(Action::LibraryQueueNext),
"Enter" => Some(Action::LibraryQueueReplace),
_ => None,
},
Focus::Queue => match key {
"j" | "ArrowDown" => Some(Action::QueueNext),
"k" | "ArrowUp" => Some(Action::QueuePrev),
"g" => Some(Action::QueueFirst),
"G" => Some(Action::QueueLast),
"o" => Some(Action::QueueSelectCurrent),
"Enter" => Some(Action::QueuePlaySelected),
"s" => Some(Action::QueueToggleMark),
"v" | "V" => Some(Action::QueueVisualMode),
"y" => Some(Action::QueueYank),
"p" => Some(Action::QueuePaste),
"P" => Some(Action::QueuePasteBefore),
"d" => Some(Action::QueueRemoveTrack),
"c" => Some(Action::QueueClearKeepCurrent),
"C" => Some(Action::QueueClearAll),
"w" => Some(Action::QueueSaveAs),
"u" => Some(Action::QueueDedup),
"U" => Some(Action::QueueDedupTitles),
"S" => Some(Action::QueueSortMenu),
_ => None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pane_focus_decides_shared_chords() {
assert_eq!(
lookup(Focus::Library, false, "d", false),
Some(Action::LibraryDeleteNode)
);
assert_eq!(
lookup(Focus::Queue, false, "d", false),
Some(Action::QueueRemoveTrack)
);
assert_eq!(
lookup(Focus::Library, false, "d", true),
Some(Action::LibraryJumpDown)
);
}
/// Seek is a control chord in either pane, and claiming `Ctrl-f` leaves
/// plain `f` alone (the ctrl branch is checked first and separately).
#[test]
fn seek_is_on_the_control_chords_in_both_panes() {
for focus in [Focus::Library, Focus::Queue] {
assert_eq!(lookup(focus, false, ".", false), Some(Action::SeekForward));
assert_eq!(lookup(focus, false, ",", false), Some(Action::SeekBackward));
}
// Unmodified `b` is unbound; unmodified `f` is not seek.
assert_eq!(lookup(Focus::Library, false, "b", false), None);
assert_ne!(
lookup(Focus::Library, false, "f", false),
Some(Action::SeekForward)
);
// Help is modal for control chords too.
assert_eq!(lookup(Focus::Library, true, "f", true), None);
}
/// `Ctrl-n` cannot be claimed in a browser — Chrome and Firefox reserve it
/// above the page — so track skipping must also be reachable without it.
#[test]
fn track_skipping_does_not_depend_on_ctrl_n() {
for focus in [Focus::Library, Focus::Queue] {
assert_eq!(lookup(focus, false, ">", false), Some(Action::NextTrack));
assert_eq!(lookup(focus, false, "<", false), Some(Action::PrevTrack));
}
// Ctrl-p is claimable and stays, so TUI habits still work.
assert_eq!(
lookup(Focus::Library, false, "p", true),
Some(Action::PrevTrack)
);
// And the help overlay documents the chords that actually work.
assert!(HELP.iter().any(|h| h.key == "< / >"));
}
#[test]
fn globals_win_in_both_panes() {
for focus in [Focus::Library, Focus::Queue] {
assert_eq!(lookup(focus, false, " ", false), Some(Action::TogglePlay));
assert_eq!(
lookup(focus, false, "z", false),
Some(Action::ToggleShuffle)
);
assert_eq!(lookup(focus, false, "n", true), Some(Action::NextTrack));
}
}
#[test]
fn help_is_modal() {
assert_eq!(lookup(Focus::Library, true, "j", false), None);
assert_eq!(
lookup(Focus::Library, true, "Escape", false),
Some(Action::CloseHelp)
);
assert_eq!(
lookup(Focus::Library, true, "?", false),
Some(Action::CloseHelp)
);
}
#[test]
fn arrows_alias_the_vim_movement() {
assert_eq!(
lookup(Focus::Library, false, "ArrowDown", false),
Some(Action::LibraryNext)
);
assert_eq!(
lookup(Focus::Library, false, "ArrowLeft", false),
Some(Action::LibraryAscend)
);
assert_eq!(
lookup(Focus::Queue, false, "ArrowUp", false),
Some(Action::QueuePrev)
);
}
/// The queue-order keys are the TUI's, in the queue pane only
/// (architecture/queue-order.md D16).
#[test]
fn queue_order_keys_match_the_tui() {
assert_eq!(
lookup(Focus::Queue, false, "u", false),
Some(Action::QueueDedup)
);
assert_eq!(
lookup(Focus::Queue, false, "S", false),
Some(Action::QueueSortMenu)
);
assert_eq!(
lookup(Focus::Queue, false, "U", false),
Some(Action::QueueDedupTitles)
);
assert_eq!(lookup(Focus::Library, false, "u", false), None);
assert_eq!(lookup(Focus::Library, false, "U", false), None);
assert_eq!(lookup(Focus::Library, false, "S", false), None);
}
#[test]
fn the_sort_menu_maps_keys_to_strategies_and_closes_on_escape() {
assert_eq!(
sort_menu_key("a"),
Some(Action::QueueSort {
sort: QueueSort::Artist,
descending: false
})
);
assert_eq!(
sort_menu_key("T"),
Some(Action::QueueSort {
sort: QueueSort::Title,
descending: true
})
);
// Reverse has no direction on the wire (D6).
for key in ["r", "R"] {
assert_eq!(
sort_menu_key(key),
Some(Action::QueueSort {
sort: QueueSort::Reverse,
descending: false
})
);
}
for closer in ["Escape", "q", "S"] {
assert_eq!(sort_menu_key(closer), Some(Action::CloseSortMenu));
}
// An unclaimed key leaves the menu open and does nothing.
for stray in ["c", "x", "Enter", "ArrowDown"] {
assert_eq!(sort_menu_key(stray), None, "{stray} is not a strategy");
}
}
/// Both menus offer the same strategies under the same keys, which is only
/// true while this table and `cbd-tui/src/app/sort.rs` agree.
#[test]
fn the_sort_table_is_consistent() {
let mut seen = Vec::new();
for choice in SORT_CHOICES {
assert!(choice.key.is_ascii_lowercase(), "{:?}", choice.key);
assert!(!seen.contains(&choice.key), "duplicate {:?}", choice.key);
assert!(!choice.description.trim().is_empty());
seen.push(choice.key);
}
assert_eq!(seen, vec!['a', 'l', 't', 'd', 'r']);
}
#[test]
fn every_action_reachable_from_help_table() {
// The help overlay documents at least every scope we bind.
assert!(HELP.iter().any(|h| h.scope == "Global"));
assert!(HELP.iter().any(|h| h.scope == "Library"));
assert!(HELP.iter().any(|h| h.scope == "Queue"));
}
}

View File

@ -1,35 +0,0 @@
//! The crabidy web client (architecture/web-client.md): a Leptos CSR
//! app with the same functionality as `cbd-tui`, talking gRPC-web to
//! `crabidy-server`, which also serves this bundle.
//!
//! Only [`rpc`] and [`app`] touch the browser; [`state`] and [`keymap`]
//! are pure and unit-tested on the native target (`cargo test -p
//! cbd-web`).
// The pure modules are consumed by the wasm `app` and by the native
// tests; the native *binary* target uses neither, so allow dead code
// there while keeping the wasm build (where it all runs) fully linted.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
mod keymap;
mod state;
#[cfg(target_arch = "wasm32")]
mod app;
#[cfg(target_arch = "wasm32")]
mod rpc;
#[cfg(target_arch = "wasm32")]
fn main() {
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(app::App);
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
// The native build exists for the unit tests of the pure modules;
// the real artifact is the wasm bundle built by trunk.
eprintln!(
"cbd-web is a browser app: build it with `trunk build` and let crabidy-server serve it"
);
}

View File

@ -1,331 +0,0 @@
//! gRPC-web transport: the same generated `crabidy-core` client the
//! TUI uses, over `tonic-web-wasm-client` against the origin that
//! served this app (architecture/web-client.md). Credentials, when the
//! server requires them, ride as the same `authorization: Basic`
//! header the TUI sends; the header value is never logged.
use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
GetUpdateStreamResponse, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
};
use tonic::{
metadata::MetadataValue,
service::{interceptor::InterceptedService, Interceptor},
Request, Status, Streaming,
};
use tonic_web_wasm_client::Client as WasmClient;
/// Attaches the stored `authorization` header to every request; without
/// credentials it attaches nothing (open server).
#[derive(Clone)]
pub struct AuthInterceptor {
header: Option<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
/// `user` empty means "no credentials". The pair is base64-encoded
/// exactly like the TUI's interceptor.
pub fn new(user: &str, password: &str) -> Option<Self> {
if user.is_empty() {
return Some(Self { header: None });
}
let encoded = base64_encode(format!("{user}:{password}").as_bytes());
let header = format!("Basic {encoded}").parse().ok()?;
Some(Self {
header: Some(header),
})
}
}
impl Interceptor for AuthInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
/// Standard base64 without pulling the base64 crate into the wasm
/// bundle for one call site.
fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
let chars = [
ALPHABET[(n >> 18) as usize & 63],
ALPHABET[(n >> 12) as usize & 63],
ALPHABET[(n >> 6) as usize & 63],
ALPHABET[n as usize & 63],
];
let keep = chunk.len() + 1;
for (i, c) in chars.iter().enumerate() {
out.push(if i < keep { *c as char } else { '=' });
}
}
out
}
type Client = CrabidyServiceClient<InterceptedService<WasmClient, AuthInterceptor>>;
/// The app's connection: thin async wrappers over the generated
/// client, mirroring `cbd-tui/src/rpc.rs` (minus its cache — the
/// caching rule lives in `state::is_cacheable` and is applied by the
/// caller, which owns the reactive store).
#[derive(Clone)]
pub struct Rpc {
client: Client,
}
impl Rpc {
/// Connects to `base_url` (normally the serving origin) with
/// optional credentials.
pub fn new(base_url: String, user: &str, password: &str) -> Option<Self> {
let interceptor = AuthInterceptor::new(user, password)?;
let client = CrabidyServiceClient::with_interceptor(WasmClient::new(base_url), interceptor);
Some(Self { client })
}
pub async fn update_stream(&mut self) -> Result<Streaming<GetUpdateStreamResponse>, Status> {
let response = self
.client
.get_update_stream(Request::new(GetUpdateStreamRequest {}))
.await?;
Ok(response.into_inner())
}
pub async fn init(&mut self) -> Result<crabidy_core::proto::crabidy::InitResponse, Status> {
Ok(self
.client
.init(Request::new(InitRequest {}))
.await?
.into_inner())
}
pub async fn get_library_node(&mut self, path: &str) -> Result<Option<LibraryNode>, Status> {
let request = Request::new(GetLibraryNodeRequest {
path: path.to_string(),
});
Ok(self
.client
.get_library_node(request)
.await?
.into_inner()
.node)
}
pub async fn create_library_node(
&mut self,
parent_path: &str,
title: &str,
) -> Result<Option<LibraryNode>, Status> {
let request = Request::new(CreateLibraryNodeRequest {
parent_path: parent_path.to_string(),
title: title.to_string(),
});
Ok(self
.client
.create_library_node(request)
.await?
.into_inner()
.node)
}
pub async fn rename_library_node(
&mut self,
path: &str,
new_title: &str,
) -> Result<Option<LibraryNode>, Status> {
let request = Request::new(RenameLibraryNodeRequest {
path: path.to_string(),
new_title: new_title.to_string(),
});
Ok(self
.client
.rename_library_node(request)
.await?
.into_inner()
.node)
}
pub async fn delete_library_node(&mut self, path: &str) -> Result<Option<LibraryNode>, Status> {
let request = Request::new(DeleteLibraryNodeRequest {
path: path.to_string(),
});
Ok(self
.client
.delete_library_node(request)
.await?
.into_inner()
.parent)
}
pub async fn capture_library_node(
&mut self,
path: &str,
name: &str,
download: bool,
) -> Result<(), Status> {
let request = Request::new(CaptureLibraryNodeRequest {
path: path.to_string(),
name: name.to_string(),
download,
});
let _ = self.client.capture_library_node(request).await?;
Ok(())
}
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Status> {
let _ = self
.client
.replace(Request::new(ReplaceRequest { paths }))
.await?;
Ok(())
}
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Status> {
let _ = self
.client
.append(Request::new(AppendRequest { paths }))
.await?;
Ok(())
}
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Status> {
let _ = self
.client
.queue(Request::new(QueueRequest { paths }))
.await?;
Ok(())
}
pub async fn insert_tracks(&mut self, position: u32, paths: Vec<String>) -> Result<(), Status> {
let request = Request::new(InsertRequest { position, paths });
let _ = self.client.insert(request).await?;
Ok(())
}
pub async fn remove_tracks(&mut self, positions: Vec<u32>) -> Result<(), Status> {
let request = Request::new(RemoveRequest { positions });
let _ = self.client.remove(request).await?;
Ok(())
}
pub async fn clear_queue(&mut self, exclude_current: bool) -> Result<(), Status> {
let request = Request::new(ClearQueueRequest { exclude_current });
let _ = self.client.clear_queue(request).await?;
Ok(())
}
/// Drops duplicate queue entries; returns how many went
/// (architecture/queue-order.md D2).
pub async fn dedup_queue(&mut self, by_title: bool) -> Result<u32, Status> {
let response = self
.client
.dedup_queue(Request::new(DedupQueueRequest { by_title }))
.await?;
Ok(response.into_inner().removed)
}
/// Reorders the queue; the new order arrives on the update stream.
pub async fn sort_queue(&mut self, sort: QueueSort, descending: bool) -> Result<(), Status> {
let request = Request::new(SortQueueRequest {
sort: sort as i32,
descending,
});
let _ = self.client.sort_queue(request).await?;
Ok(())
}
pub async fn set_current(&mut self, position: u32) -> Result<(), Status> {
let request = Request::new(SetCurrentRequest { position });
let _ = self.client.set_current(request).await?;
Ok(())
}
pub async fn save_queue(&mut self, name: &str) -> Result<(), Status> {
let request = Request::new(SaveQueueRequest {
name: name.to_string(),
});
let _ = self.client.save_queue(request).await?;
Ok(())
}
pub async fn toggle_play(&mut self) -> Result<(), Status> {
let _ = self
.client
.toggle_play(Request::new(TogglePlayRequest {}))
.await?;
Ok(())
}
pub async fn restart_track(&mut self) -> Result<(), Status> {
let _ = self
.client
.restart_track(Request::new(RestartTrackRequest {}))
.await?;
Ok(())
}
/// Moves the playing position by a signed millisecond offset. The server
/// applies it to the live position and clamps it to the track, so this
/// carries the *step*, never a target (architecture/seek.md D1).
pub async fn seek(&mut self, delta_millis: i32) -> Result<(), Status> {
let request = Request::new(SeekRequest { delta_millis });
let _ = self.client.seek(request).await?;
Ok(())
}
pub async fn next(&mut self) -> Result<(), Status> {
let _ = self.client.next(Request::new(NextRequest {})).await?;
Ok(())
}
pub async fn prev(&mut self) -> Result<(), Status> {
let _ = self.client.prev(Request::new(PrevRequest {})).await?;
Ok(())
}
pub async fn change_volume(&mut self, delta: f32) -> Result<(), Status> {
let request = Request::new(ChangeVolumeRequest { delta });
let _ = self.client.change_volume(request).await?;
Ok(())
}
pub async fn toggle_mute(&mut self) -> Result<(), Status> {
let _ = self
.client
.toggle_mute(Request::new(ToggleMuteRequest {}))
.await?;
Ok(())
}
pub async fn toggle_shuffle(&mut self) -> Result<(), Status> {
let _ = self
.client
.toggle_shuffle(Request::new(ToggleShuffleRequest {}))
.await?;
Ok(())
}
pub async fn toggle_repeat(&mut self) -> Result<(), Status> {
let _ = self
.client
.toggle_repeat(Request::new(ToggleRepeatRequest {}))
.await?;
Ok(())
}
}

View File

@ -1,975 +0,0 @@
//! Pure client state — the web port of the TUI's pane logic
//! (`cbd-tui/src/app/{library,queue,mod}.rs`), free of DOM and
//! transport so it unit-tests on the native target. Components own
//! these values inside Leptos signals and call the methods on updates.
use std::collections::HashMap;
use crabidy_core::proto::crabidy::{CaptureProgress, LibraryNode, Track};
/// Which pane has keyboard focus (`Tab` toggles, like the TUI).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
Library,
Queue,
}
/// Why the one-line name dialog is open — the web port of the TUI's
/// `InputPurpose`, deciding the submit RPC and the dialog label.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NamePurpose {
/// `%`: create a child (search term) under the creatable node.
Create { parent_path: String },
/// `e`: rename the node at `path` (prefilled with its title).
Rename { path: String },
/// `w` in the queue pane: save the queue under the entered name.
SaveQueue,
/// `w`/`W` in the library: bookmark or download-capture `path`.
Capture { path: String, download: bool },
}
impl NamePurpose {
/// The dialog label; capture warns about duration like the TUI.
pub fn label(&self) -> &'static str {
match self {
NamePurpose::Create { .. } => "new node",
NamePurpose::Rename { .. } => "rename",
NamePurpose::SaveQueue => "save queue",
NamePurpose::Capture {
download: false, ..
} => "bookmark",
NamePurpose::Capture { download: true, .. } => "capture (slow, resumable)",
}
}
}
/// A modal dialog. At most one is open; while one is open, keys go to
/// it (the keymap is bypassed, mirroring the TUI's modal overlays).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Dialog {
/// Text input with a purpose-dependent submit.
Name {
purpose: NamePurpose,
buffer: String,
},
/// Credentials form. Shown on `UNAUTHENTICATED` responses (creds
/// required), and proactively — but dismissible — on first connect
/// when the server reports auth is enabled and we hold none.
Login,
/// The `?` key binding overlay.
Help,
/// The queue sort menu (`S`): one keypress picks a strategy. Keys reach
/// the dispatcher while it is open, like `Help`
/// (architecture/queue-order.md D14, D16).
Sort,
}
/// Whether a library listing may be cached client-side — same rule as
/// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate
/// behind the client's back and are cheap to re-list. `/crabidy` (saves and
/// captures), `/fs` (files on disk), and `/orphans` (recomputed from the
/// store on every visit) all change server-side, so their listings are
/// always refetched.
pub fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
!MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UiItemKind {
Track,
Node,
}
/// One row of the library pane — the TUI's `UiItem`, unchanged.
#[derive(Clone, Debug, PartialEq)]
pub struct UiItem {
pub path: String,
pub title: String,
pub kind: UiItemKind,
pub marked: bool,
pub is_queable: bool,
pub is_creatable: bool,
pub is_editable: bool,
pub is_deletable: bool,
pub is_downloadable: bool,
pub is_skipped: bool,
}
/// The library pane: current listing, cursor, marks, and per-path
/// cursor memory (going back re-selects where you were).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LibraryPane {
pub path: String,
pub title: String,
pub parent: Option<String>,
pub is_creatable: bool,
pub items: Vec<UiItem>,
pub selected: usize,
/// Visual (paint-select) mode: `Some(anchor)` while active. Movement
/// toggles the marks of the range between the anchor and the cursor, so
/// moving back reverses — the TUI's rule, same code shape.
pub visual: Option<usize>,
positions: HashMap<String, usize>,
}
impl LibraryPane {
/// Applies a fresh listing. Mirrors the TUI: an empty, non-creatable
/// node is not entered (nothing to show, nothing to create), and
/// tracks list before child nodes.
pub fn update(&mut self, node: &LibraryNode) {
if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() {
return;
}
self.positions.insert(self.path.clone(), self.selected);
self.path = node.path.clone();
self.title = node.title.clone();
self.parent = node.parent.clone();
self.is_creatable = node.is_creatable;
self.items = node
.tracks
.iter()
.map(|t| UiItem {
path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: false,
is_queable: true,
is_creatable: false,
is_editable: false,
// Tracks inherit their node's blessing, like the TUI.
is_deletable: node.tracks_deletable,
is_downloadable: node.is_downloadable,
is_skipped: t.is_skipped,
})
.chain(node.children.iter().map(|c| UiItem {
path: c.path.clone(),
title: c.title.clone(),
kind: UiItemKind::Node,
marked: false,
is_queable: c.is_queable,
is_creatable: c.is_creatable,
is_editable: c.is_editable,
is_deletable: c.is_deletable,
is_downloadable: c.is_downloadable,
is_skipped: false,
}))
.collect();
self.selected = self
.positions
.get(&self.path)
.copied()
.unwrap_or(0)
.min(self.items.len().saturating_sub(1));
}
pub fn selected_item(&self) -> Option<&UiItem> {
self.items.get(self.selected)
}
/// Cursor movement; `delta` may over/undershoot (jump keys).
pub fn select_by(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
let last = self.items.len() - 1;
self.selected = self.selected.saturating_add_signed(delta).min(last);
}
pub fn select_first(&mut self) {
self.selected = 0;
}
pub fn select_last(&mut self) {
self.selected = self.items.len().saturating_sub(1);
}
pub fn select(&mut self, index: usize) {
if index < self.items.len() {
self.selected = index;
}
}
/// `Space`: toggles the mark of the selection (queueable items only).
pub fn toggle_mark(&mut self) {
if let Some(item) = self.items.get_mut(self.selected) {
if item.is_queable {
item.marked = !item.marked;
}
}
}
/// Toggle the mark of one row, honoring the queueable gate.
fn toggle_mark_at(&mut self, index: usize) {
if let Some(item) = self.items.get_mut(index) {
if item.is_queable {
item.marked = !item.marked;
}
}
}
/// Enter or leave visual mode. Entering anchors at the cursor and marks
/// it; leaving keeps the marks.
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.selected);
self.toggle_mark();
}
}
pub fn exit_visual(&mut self) {
self.visual = None;
}
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// Paint a visual-mode sweep: toggle every row whose membership in the
/// anchored range changed, so moving back over a row reverses it.
pub fn paint_between(&mut self, from: usize, to: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&index);
let in_new = (new_lo..=new_hi).contains(&index);
if in_old != in_new {
self.toggle_mark_at(index);
}
}
}
/// What `y` puts in the register: the marked rows, or the queueable
/// cursor row — paths with labels for display.
pub fn yank_selection(&self) -> Option<(Vec<String>, Vec<String>)> {
if self.items.iter().any(|i| i.marked) {
let marked = self.items.iter().filter(|i| i.marked);
return Some((
marked.clone().map(|i| i.path.clone()).collect(),
marked.map(|i| i.title.clone()).collect(),
));
}
let item = self.selected_item()?;
item.is_queable
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
}
pub fn remove_marks(&mut self) {
for item in &mut self.items {
item.marked = false;
}
}
/// The paths a queue operation ships: all marked items, or the
/// bare queueable selection — exactly the TUI's `get_selected`.
pub fn queueable_selection(&self) -> Option<Vec<String>> {
if self.items.iter().any(|i| i.marked) {
return Some(
self.items
.iter()
.filter(|i| i.marked)
.map(|i| i.path.clone())
.collect(),
);
}
let item = self.selected_item()?;
item.is_queable.then(|| vec![item.path.clone()])
}
pub fn selected_editable(&self) -> Option<(String, String)> {
let item = self.selected_item()?;
item.is_editable
.then(|| (item.path.clone(), item.title.clone()))
}
pub fn selected_deletable(&self) -> Option<(String, String)> {
let item = self.selected_item()?;
item.is_deletable
.then(|| (item.path.clone(), item.title.clone()))
}
pub fn selected_queueable(&self) -> Option<(String, String)> {
let item = self.selected_item()?;
item.is_queable
.then(|| (item.path.clone(), item.title.clone()))
}
pub fn selected_downloadable(&self) -> Option<(String, String)> {
let item = self.selected_item()?;
(item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
}
}
/// The queue pane cursor. The queue itself (tracks, current position,
/// play state) lives in signals fed by the update stream; this only
/// tracks the selection.
// No longer `Copy`: it owns the mark flags and the paths they were taken
// against (architecture/queue-register.md).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct QueueCursor {
pub selected: usize,
/// Marks, one flag per queue position. The queue is server-pushed and has
/// no client-owned list, so these sit beside the snapshot and are carried
/// across updates by [`carry_marks`].
pub marks: Vec<bool>,
/// The track paths `marks` was taken against, so the next snapshot can be
/// matched against it.
paths: Vec<String>,
/// Visual (paint-select) mode anchor, as in the library.
pub visual: Option<usize>,
}
impl QueueCursor {
pub fn select_by(&mut self, delta: isize, len: usize) {
if len == 0 {
return;
}
self.selected = self.selected.saturating_add_signed(delta).min(len - 1);
}
pub fn clamp(&mut self, len: usize) {
self.selected = self.selected.min(len.saturating_sub(1));
}
/// Apply a fresh queue snapshot: marks follow their own track rather than
/// their old index, so a mark cannot silently retarget when playback
/// advances or another client edits the queue.
pub fn reconcile(&mut self, new_paths: Vec<String>) {
self.marks = carry_marks(&self.paths, &self.marks, &new_paths);
self.paths = new_paths;
self.clamp(self.marks.len());
}
pub fn has_marks(&self) -> bool {
self.marks.iter().any(|m| *m)
}
/// The positions an action applies to: every marked row, or the cursor
/// row when nothing is marked.
pub fn action_positions(&self) -> Vec<usize> {
if self.has_marks() {
return self
.marks
.iter()
.enumerate()
.filter(|(_, marked)| **marked)
.map(|(pos, _)| pos)
.collect();
}
if self.marks.is_empty() {
return Vec::new();
}
vec![self.selected]
}
fn toggle_mark_at(&mut self, index: usize) {
if let Some(mark) = self.marks.get_mut(index) {
*mark = !*mark;
}
}
/// Every queue row may be marked — unlike the library there is no
/// queueable gate to apply.
pub fn toggle_mark(&mut self) {
self.toggle_mark_at(self.selected);
}
pub fn remove_marks(&mut self) {
for mark in &mut self.marks {
*mark = false;
}
}
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.selected);
self.toggle_mark();
}
}
pub fn exit_visual(&mut self) {
self.visual = None;
}
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// The library's paint rule, over positions instead of view rows.
pub fn paint_between(&mut self, from: usize, to: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&index);
let in_new = (new_lo..=new_hi).contains(&index);
if in_old != in_new {
self.toggle_mark_at(index);
}
}
}
/// The insert position for a paste: after the cursor for `p`, at the
/// cursor for `P`. An empty queue pastes at the front.
pub fn paste_position(&self, before: bool) -> u32 {
if self.marks.is_empty() {
return 0;
}
let pos = if before {
self.selected
} else {
self.selected + 1
};
pos as u32
}
}
/// What `y`, `d`, `c`, and `C` set aside and `p`/`P` paste back — one unnamed
/// in-memory slot of library paths, with labels for display only. Overwritten
/// by each write; paste re-resolves the paths, so a yanked node expands at
/// paste time.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Register {
paths: Vec<String>,
labels: Vec<String>,
}
impl Register {
pub fn set(&mut self, paths: Vec<String>, labels: Vec<String>) {
self.paths = paths;
self.labels = labels;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
pub fn paths(&self) -> &[String] {
&self.paths
}
pub fn labels(&self) -> &[String] {
&self.labels
}
}
/// Carry queue marks across a server snapshot by a greedy in-order match on
/// track path — the same rule the TUI applies (`cbd-tui/src/app/list.rs`).
///
/// A mark follows its track through appends, removals, and playback
/// advancing; a mark whose track is gone is dropped; duplicate paths pair up
/// in order. Nothing in common means no marks rather than a guess.
pub fn carry_marks(old_paths: &[String], old_marked: &[bool], new_paths: &[String]) -> Vec<bool> {
let mut carried = vec![false; new_paths.len()];
let mut old_idx = 0;
for (new_idx, path) in new_paths.iter().enumerate() {
while old_idx < old_paths.len() && &old_paths[old_idx] != path {
old_idx += 1;
}
if old_idx < old_paths.len() {
carried[new_idx] = old_marked.get(old_idx).copied().unwrap_or(false);
old_idx += 1;
}
}
carried
}
/// How long a finished capture's line lingers, in milliseconds —
/// the TUI's `CaptureBoard` with an injected clock (the browser has
/// `performance.now()`, tests pass plain numbers).
const CAPTURE_DONE_LINGER_MS: f64 = 5_000.0;
const CAPTURE_ERROR_LINGER_MS: f64 = 10_000.0;
struct CaptureEntry {
progress: CaptureProgress,
finished_at: Option<f64>,
}
/// Live capture progress lines, keyed by capture name.
#[derive(Default)]
pub struct CaptureBoard {
entries: Vec<CaptureEntry>,
}
impl CaptureBoard {
/// Applies one stream update at time `now_ms`.
pub fn apply(&mut self, progress: CaptureProgress, now_ms: f64) {
let finished_at = progress.finished.then_some(now_ms);
let entry = CaptureEntry {
progress,
finished_at,
};
match self
.entries
.iter_mut()
.find(|e| e.progress.name == entry.progress.name)
{
Some(existing) => *existing = entry,
None => self.entries.push(entry),
}
}
/// The lines to render at `now_ms`, oldest first, with an is-error
/// flag; expired finished entries are dropped.
pub fn lines(&mut self, now_ms: f64) -> Vec<(String, bool)> {
self.entries.retain(|e| match e.finished_at {
None => true,
Some(at) if e.progress.error.is_empty() => now_ms - at < CAPTURE_DONE_LINGER_MS,
Some(at) => now_ms - at < CAPTURE_ERROR_LINGER_MS,
});
self.entries
.iter()
.map(|e| (Self::line(&e.progress), !e.progress.error.is_empty()))
.collect()
}
/// One entry's display line — character for character the TUI's.
fn line(p: &CaptureProgress) -> String {
let verb = if p.download {
("capturing", "captured", "capture")
} else {
("bookmarking", "bookmarked", "bookmark")
};
let skipped = if p.tracks_skipped > 0 {
format!(" ({} skipped)", p.tracks_skipped)
} else {
String::new()
};
if !p.finished {
let total = if p.tracks_total > 0 {
p.tracks_total.to_string()
} else {
"?".to_string()
};
format!("{} {} {}/{total}{skipped}", verb.0, p.name, p.tracks_done)
} else if p.error.is_empty() {
format!("{} {}: {} tracks{skipped}", verb.1, p.name, p.tracks_done)
} else {
format!("{} {} failed: {}", verb.2, p.name, p.error)
}
}
}
/// The seek offset in milliseconds that a click `fraction` of the way along
/// the progress bar means, given where playback currently is.
///
/// Seeking is relative on the wire (`architecture/seek.md` D1), so a click on
/// an absolute position becomes "the difference between there and here". That
/// is fine for a one-shot gesture: the position it subtracts is the one drawn
/// on the bar the user just aimed at, and it is at most one 250 ms tick old —
/// far below one pixel of a progress bar for any track worth seeking in. (It
/// would *not* be fine for a repeated key, which is why the keys send a fixed
/// step and let the server accumulate.)
///
/// `None` when the click cannot mean anything: a track whose duration the
/// source never reported has no scale, so the bar has no position to click at.
/// The fraction is clamped, so a click on the very edge of the element — or a
/// rounding error past it — cannot ask for a position outside the track.
pub fn seek_offset_for_fraction(
position_millis: u32,
duration_millis: u32,
fraction: f64,
) -> Option<i32> {
if duration_millis == 0 || !fraction.is_finite() {
return None;
}
let target = f64::from(duration_millis) * fraction.clamp(0.0, 1.0);
// Both terms are millisecond offsets inside one track, so the difference is
// nowhere near `i32`; the clamp is belt-and-braces for a `duration` a feed
// could have lied about.
let delta = target.round() - f64::from(position_millis);
Some(delta.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32)
}
/// `mm:ss`, or `h:mm:ss` once past an hour — matching the TUI's now-playing
/// clock so minutes zero-pad and roll into hours instead of counting past 60.
pub fn format_seconds(total: u32) -> String {
let secs = total % 60;
let mins = (total / 60) % 60;
let hours = total / 3600;
if hours > 0 {
format!("{hours}:{mins:02}:{secs:02}")
} else {
format!("{mins:02}:{secs:02}")
}
}
/// The now-playing line for a track, `artist - title` falling back to
/// the path's last segment for artistless tracks.
pub fn track_label(track: &Track) -> String {
if track.artist.is_empty() {
track.title.clone()
} else {
format!("{} - {}", track.artist, track.title)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::LibraryNodeChild;
fn node(path: &str, tracks: usize, children: usize) -> LibraryNode {
LibraryNode {
path: path.to_string(),
title: path.trim_start_matches('/').to_string(),
children: (0..children)
.map(|i| LibraryNodeChild::new(format!("{path}/c{i}"), format!("c{i}"), true))
.collect(),
parent: Some("/".to_string()),
tracks: (0..tracks)
.map(|i| Track {
path: format!("{path}/t{i}"),
artist: "artist".to_string(),
title: format!("t{i}"),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
})
.collect(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// Clicking the progress bar has to become an *offset*, because that is
/// what the wire carries — and it must stay inside the track however the
/// pointer geometry comes out.
#[test]
fn a_progress_bar_click_becomes_an_offset() {
// Half way through a 10-minute track, currently at 1:00 → +4:00.
assert_eq!(
seek_offset_for_fraction(60_000, 600_000, 0.5),
Some(240_000)
);
// Clicking behind the playhead seeks backwards.
assert_eq!(
seek_offset_for_fraction(300_000, 600_000, 0.25),
Some(-150_000)
);
// The far edges are exactly the ends of the track, and a fraction that
// overshoots (rounding, a click on the border) is clamped, never
// extrapolated past it.
assert_eq!(seek_offset_for_fraction(0, 600_000, 0.0), Some(0));
assert_eq!(seek_offset_for_fraction(0, 600_000, 1.0), Some(600_000));
assert_eq!(seek_offset_for_fraction(0, 600_000, 1.7), Some(600_000));
assert_eq!(seek_offset_for_fraction(0, 600_000, -0.4), Some(0));
}
/// A bar with no scale cannot be clicked into a position: a length-less
/// stream reports duration 0, and a non-finite fraction means the element
/// had no width. Both must decline rather than seek somewhere arbitrary.
#[test]
fn a_click_without_a_scale_is_declined() {
assert_eq!(seek_offset_for_fraction(5_000, 0, 0.5), None);
assert_eq!(seek_offset_for_fraction(5_000, 600_000, f64::NAN), None);
assert_eq!(
seek_offset_for_fraction(5_000, 600_000, f64::INFINITY),
None
);
// And nothing panics on absurd inputs.
assert!(seek_offset_for_fraction(u32::MAX, u32::MAX, 1.0).is_some());
assert!(seek_offset_for_fraction(0, u32::MAX, 1.0).is_some());
}
#[test]
fn listings_order_tracks_before_children_and_remember_positions() {
let mut pane = LibraryPane::default();
pane.update(&node("/a", 2, 2));
assert_eq!(pane.items.len(), 4);
assert_eq!(pane.items[0].kind, UiItemKind::Track);
assert_eq!(pane.items[2].kind, UiItemKind::Node);
pane.select_by(3);
assert_eq!(pane.selected, 3, "clamped to the last item");
pane.update(&node("/a/c1", 1, 0));
assert_eq!(pane.selected, 0, "fresh node starts at the top");
pane.update(&node("/a", 2, 2));
assert_eq!(pane.selected, 3, "back-navigation restores the cursor");
}
#[test]
fn empty_non_creatable_nodes_are_not_entered() {
let mut pane = LibraryPane::default();
pane.update(&node("/a", 1, 0));
let empty = node("/a/empty", 0, 0);
pane.update(&empty);
assert_eq!(pane.path, "/a", "listing unchanged");
let mut creatable = node("/tidal/search", 0, 0);
creatable.is_creatable = true;
pane.update(&creatable);
assert_eq!(pane.path, "/tidal/search", "creatable nodes open empty");
}
#[test]
fn marks_collect_and_bare_selection_falls_back() {
let mut pane = LibraryPane::default();
pane.update(&node("/a", 2, 1));
assert_eq!(
pane.queueable_selection(),
Some(vec!["/a/t0".to_string()]),
"bare selection"
);
pane.toggle_mark();
pane.select_by(2);
pane.toggle_mark();
assert_eq!(
pane.queueable_selection(),
Some(vec!["/a/t0".to_string(), "/a/c0".to_string()]),
"marks win over the cursor"
);
pane.remove_marks();
assert!(pane.items.iter().all(|i| !i.marked));
}
#[test]
fn skipped_and_deletable_flags_reach_the_items() {
let mut listing = node("/captures/mix", 1, 0);
listing.tracks_deletable = true;
listing.tracks[0].is_skipped = true;
let mut pane = LibraryPane::default();
pane.update(&listing);
assert!(pane.items[0].is_skipped);
assert!(pane.items[0].is_deletable, "tracks inherit the node flag");
assert_eq!(
pane.selected_deletable(),
Some(("/captures/mix/t0".to_string(), "artist - t0".to_string()))
);
}
#[test]
fn mutable_roots_are_never_cacheable() {
for path in [
"/crabidy",
"/crabidy/faves",
"/fs/music",
"/orphans",
"/orphans/stray.flac",
] {
assert!(!is_cacheable(path), "{path}");
}
for path in [
"/tidal/playlists",
"/youtube/search",
"/crabidystore",
"/orphansaurus",
] {
assert!(is_cacheable(path), "{path}");
}
}
#[test]
fn capture_board_lines_match_the_tui_and_expire() {
let mut board = CaptureBoard::default();
board.apply(
CaptureProgress {
name: "mix".into(),
download: true,
tracks_done: 3,
tracks_total: 9,
tracks_skipped: 1,
finished: false,
error: String::new(),
},
0.0,
);
assert_eq!(
board.lines(0.0),
vec![("capturing mix 3/9 (1 skipped)".to_string(), false)]
);
board.apply(
CaptureProgress {
name: "mix".into(),
download: true,
tracks_done: 9,
tracks_total: 9,
tracks_skipped: 1,
finished: true,
error: String::new(),
},
1_000.0,
);
assert_eq!(
board.lines(1_000.0),
vec![("captured mix: 9 tracks (1 skipped)".to_string(), false)]
);
assert!(board.lines(7_000.0).is_empty(), "done lines expire");
}
#[test]
fn time_formatting_is_mm_ss() {
assert_eq!(format_seconds(0), "00:00");
assert_eq!(format_seconds(61), "01:01");
assert_eq!(format_seconds(3599), "59:59");
// Past an hour it rolls into h:mm:ss instead of counting past 60 min.
assert_eq!(format_seconds(3600), "1:00:00");
assert_eq!(format_seconds(3661), "1:01:01");
}
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
/// The same reconciliation cases the TUI pins, so the two clients cannot
/// drift (quality/queue-register.md G18).
#[test]
fn marks_follow_their_track_across_a_snapshot() {
// Unchanged.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a", "/b"])),
vec![false, true]
);
// Append leaves earlier marks alone.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a", "/b", "/c"])),
vec![false, true, false]
);
// A removal before the mark shifts it down.
assert_eq!(
carry_marks(
&v(&["/a", "/b", "/c"]),
&[false, false, true],
&v(&["/b", "/c"])
),
vec![false, true]
);
// The marked track itself is gone.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a"])),
vec![false]
);
// Nothing in common: no marks rather than a guess.
assert_eq!(
carry_marks(&v(&["/a"]), &[true], &v(&["/x", "/y"])),
vec![false, false]
);
// Duplicates pair up in order.
assert_eq!(
carry_marks(&v(&["/a", "/a"]), &[false, true], &v(&["/a", "/a"])),
vec![false, true]
);
// A short mark vector must not panic.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[true], &v(&["/a", "/b"])),
vec![true, false]
);
}
#[test]
fn reconcile_carries_marks_and_clamps_the_cursor() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.selected = 2;
cursor.toggle_mark();
assert_eq!(cursor.action_positions(), vec![2]);
// Playback drops the head: the mark follows /c to position 1.
cursor.reconcile(v(&["/b", "/c"]));
assert_eq!(cursor.action_positions(), vec![1]);
assert!(cursor.selected < 2, "cursor clamped into the shorter queue");
}
/// Every queue row is markable — no queueable gate here (G12).
#[test]
fn the_cursor_row_is_the_default_target() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b"]));
cursor.selected = 1;
assert_eq!(cursor.action_positions(), vec![1]);
assert!(!cursor.has_marks());
}
#[test]
fn an_empty_queue_has_nothing_to_act_on() {
let cursor = QueueCursor::default();
assert!(cursor.action_positions().is_empty());
assert_eq!(cursor.paste_position(false), 0);
assert_eq!(cursor.paste_position(true), 0);
}
/// `p` after the cursor, `P` at it (G8).
#[test]
fn paste_positions_straddle_the_cursor() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.selected = 1;
assert_eq!(cursor.paste_position(false), 2);
assert_eq!(cursor.paste_position(true), 1);
}
#[test]
fn queue_visual_mode_paints_and_reverses() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.toggle_visual();
assert!(cursor.is_visual());
// Sweep down to 2.
for to in 1..=2 {
let from = cursor.selected;
cursor.selected = to;
cursor.paint_between(from, to);
}
assert_eq!(cursor.action_positions(), vec![0, 1, 2]);
// Sweep back up: the row turned around on is released.
let from = cursor.selected;
cursor.selected = 1;
cursor.paint_between(from, 1);
assert_eq!(cursor.action_positions(), vec![0, 1]);
cursor.exit_visual();
assert!(!cursor.is_visual());
assert_eq!(cursor.action_positions(), vec![0, 1], "marks survive");
}
#[test]
fn library_visual_mode_paints_under_the_queueable_gate() {
let mut pane = LibraryPane::default();
pane.update(&node("/a", 2, 0));
pane.toggle_visual();
assert!(pane.is_visual());
let from = pane.selected;
pane.selected = 1;
pane.paint_between(from, 1);
let marked: Vec<&str> = pane
.items
.iter()
.filter(|i| i.marked)
.map(|i| i.path.as_str())
.collect();
assert_eq!(marked.len(), 2, "both queueable rows painted: {marked:?}");
}
#[test]
fn a_register_write_overwrites_and_survives_reads() {
let mut reg = Register::default();
assert!(reg.is_empty());
reg.set(v(&["/a"]), v(&["A"]));
reg.set(v(&["/b", "/c"]), v(&["B", "C"]));
assert_eq!(reg.len(), 2);
assert_eq!(reg.paths(), ["/b", "/c"]);
assert_eq!(reg.labels(), ["B", "C"]);
// Reading does not consume.
assert_eq!(reg.paths(), ["/b", "/c"]);
reg.set(Vec::new(), Vec::new());
assert!(reg.is_empty());
}
}

View File

@ -1,541 +0,0 @@
/* crabidy web client pure modern CSS (architecture/web-client.md).
One accent variable (crab orange-red), light and dark themes via
color-scheme + light-dark(); the theme toggle stamps data-theme on
<html>, otherwise the OS decides. No frameworks, no external
requests everything ships in the bundle. */
:root {
color-scheme: light dark;
/* The crab. Every accent tone derives from this one value. */
--accent: oklch(0.62 0.19 35);
--accent-strong: color-mix(in oklch, var(--accent) 85%, black);
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--on-accent: oklch(0.99 0.005 60);
--bg: light-dark(oklch(0.98 0.005 60), oklch(0.17 0.01 260));
--bg-raised: light-dark(oklch(1 0 0), oklch(0.21 0.012 260));
--fg: light-dark(oklch(0.25 0.015 260), oklch(0.92 0.005 60));
--fg-dim: light-dark(oklch(0.52 0.012 260), oklch(0.68 0.008 60));
--danger: light-dark(oklch(0.54 0.2 25), oklch(0.68 0.19 25));
--border: color-mix(in oklch, var(--fg) 14%, transparent);
--shadow: 0 8px 32px light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.55));
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 15px/1.45 system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
overscroll-behavior: none;
}
button {
font: inherit;
color: inherit;
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: 6px;
padding: 0.3rem 0.75rem;
cursor: pointer;
&:hover {
background: var(--accent-strong);
}
&:disabled {
opacity: 0.35;
cursor: default;
}
&.ghost {
background: transparent;
color: var(--fg-dim);
padding: 0.25rem 0.5rem;
&:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--fg);
}
&.active {
color: var(--accent);
background: var(--accent-soft);
}
}
&.ghost.danger {
background: transparent;
color: var(--danger);
&:hover:not(:disabled) {
background: color-mix(in oklch, var(--danger) 15%, transparent);
}
}
}
input {
font: inherit;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
}
/* ---- frame ---------------------------------------------------------- */
.shell {
display: grid;
grid-template-rows: auto 1fr auto;
block-size: 100dvh;
}
.topbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
border-block-end: 1px solid var(--border);
background: var(--bg-raised);
& .brand {
color: var(--accent);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
& .conn {
font-size: 0.85rem;
color: var(--fg-dim);
/* Truncate rather than push the pane tabs off a narrow screen. */
min-inline-size: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&.offline {
color: var(--danger);
}
}
& .tabs {
display: flex;
gap: 0.1rem;
padding: 0.1rem;
border: 1px solid var(--border);
border-radius: 8px;
/* Comfortable for a thumb — this is the phone's only pane switch. */
& button {
padding: 0.3rem 0.7rem;
}
}
& .topbar-actions {
margin-inline-start: auto;
display: flex;
gap: 0.25rem;
}
}
/* ---- panes ----------------------------------------------------------- */
.panes {
display: grid;
grid-template-columns: 3fr 2fr;
min-block-size: 0;
}
.pane {
display: grid;
grid-template-rows: auto 1fr auto;
min-block-size: 0;
border-inline-end: 1px solid var(--border);
/* The focused pane shows it like the TUI's highlighted border. */
box-shadow: inset 0 2px 0 transparent;
&:last-child {
border-inline-end: none;
}
&.focused {
box-shadow: inset 0 2px 0 var(--accent);
}
}
.toolbar {
display: flex;
align-items: center;
gap: 0.15rem;
padding: 0.35rem 0.6rem;
border-block-end: 1px solid var(--border);
overflow-x: auto;
& .path {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& .spacer {
flex: 1;
}
}
.list {
margin: 0;
padding: 0.25rem 0;
list-style: none;
overflow-y: auto;
min-block-size: 0;
& li {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.28rem 0.75rem;
cursor: pointer;
border-inline-start: 3px solid transparent;
& .title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& .badge {
color: var(--fg-dim);
font-size: 0.8rem;
}
&:hover {
background: var(--accent-soft);
}
&.selected {
background: var(--accent-soft);
border-inline-start-color: var(--accent);
}
&.marked .title {
color: var(--accent);
font-weight: 600;
&::before {
content: "* ";
}
}
/* Skipped tracks carry no audio (incremental captures). */
&.skipped .title {
color: var(--danger);
}
&.node .title {
color: color-mix(in oklch, var(--fg) 80%, var(--accent));
}
&.current .title {
color: var(--accent);
font-weight: 700;
}
& .row-action {
visibility: hidden;
}
&:hover .row-action,
&.selected .row-action {
visibility: visible;
}
}
}
.capture-lines {
padding: 0.2rem 0.75rem 0.4rem;
font-size: 0.85rem;
color: var(--fg-dim);
& .capture-line.error {
color: var(--danger);
}
}
/* ---- transport -------------------------------------------------------- */
.transport {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.9rem;
border-block-start: 1px solid var(--border);
background: var(--bg-raised);
& .controls {
display: flex;
align-items: center;
gap: 0.1rem;
/* The prev/next/pause marks carry emoji presentation too, so a browser
with a color emoji font renders them as pictures rather than type.
Ask for the text form; ignored where unsupported, which costs
nothing the glyphs are the fallback either way. */
font-variant-emoji: text;
& .big {
font-size: 1.3rem;
color: var(--accent);
}
}
& .now-playing {
min-inline-size: 0;
& .np-title {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
/* Frequency-spectrum bars (architecture/spectrum.md): a row of
accent-colored columns whose heights track the streamed bins. */
& .spectrum {
display: flex;
align-items: flex-end;
gap: 1px;
block-size: 1.5rem;
margin-block-end: 0.15rem;
& .bar {
flex: 1;
min-block-size: 1px;
background: var(--accent);
border-radius: 1px;
transition: height 0.08s linear;
}
}
}
& .progress {
display: flex;
align-items: center;
gap: 0.5rem;
& .time {
font-size: 0.8rem;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
& .gauge {
flex: 1;
block-size: 6px;
border-radius: 3px;
background: var(--accent-soft);
overflow: hidden;
/* Click-to-seek: `manipulation` drops the touch double-tap delay so a
tap seeks immediately. */
cursor: pointer;
touch-action: manipulation;
& .gauge-fill {
block-size: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.4s linear;
}
}
}
& .volume {
display: flex;
align-items: center;
gap: 0.4rem;
& input[type="range"] {
inline-size: 7rem;
accent-color: var(--accent);
padding: 0;
border: none;
background: transparent;
}
}
}
/* ---- overlays ---------------------------------------------------------- */
.overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
}
.dialog {
display: grid;
gap: 0.7rem;
min-inline-size: min(26rem, 90vw);
max-block-size: 85dvh;
overflow-y: auto;
padding: 1.1rem 1.3rem;
border-radius: 10px;
background: var(--bg-raised);
box-shadow: var(--shadow);
& label {
color: var(--fg-dim);
font-size: 0.9rem;
}
& .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
}
/* Visual (paint-select) mode badge in a pane toolbar the web counterpart of
the TUI's "— VISUAL" pane title. */
.mode {
padding: 0.05rem 0.4rem;
border: 1px solid var(--accent);
border-radius: 0.6rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.help {
min-inline-size: min(52rem, 94vw);
& h2 {
margin: 0;
color: var(--accent);
}
& .help-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 0.5rem 2rem;
& h3 {
margin: 0.4rem 0 0.2rem;
font-size: 0.9rem;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
& table {
border-collapse: collapse;
inline-size: 100%;
& td {
padding: 0.12rem 0.4rem 0.12rem 0;
vertical-align: top;
}
& .key {
font-family: ui-monospace, monospace;
color: var(--accent);
white-space: nowrap;
}
}
}
}
/* The queue sort menu: one row per strategy, pickable by click as well as by
its letter (architecture/queue-order.md D16). */
.sort-choice {
cursor: pointer;
& td {
padding: 0.25rem 0.6rem 0.25rem 0;
}
& .key {
font-family: ui-monospace, monospace;
color: var(--accent);
white-space: nowrap;
}
&:hover {
background: var(--accent-soft);
}
}
.hint {
margin: 0;
color: var(--fg-dim);
font-size: 0.85rem;
}
.toast {
position: fixed;
inset-block-end: 4.5rem;
inset-inline-start: 50%;
translate: -50% 0;
padding: 0.5rem 1rem;
border-radius: 8px;
background: var(--danger);
color: var(--on-accent);
box-shadow: var(--shadow);
}
/* ---- phone ------------------------------------------------------------- */
@media (max-width: 700px) {
/* One pane at a time, switched by the topbar tabs (or `Tab`). The other
pane is gone rather than collapsed to a strip: a strip is a row of
truncated rows and toolbar buttons that still take taps, so aiming at
it hit the wrong pane's list or its "play". The tabs replace it. */
.panes {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.pane {
/* No second column to divide, so the border would sit on the screen
edge and `:last-child` only spares the queue. */
border-inline-end: none;
}
.pane:not(.focused) {
display: none;
}
.transport {
grid-template-columns: 1fr;
gap: 0.4rem;
& .volume {
justify-content: flex-end;
}
}
}

View File

@ -1,51 +0,0 @@
[package]
name = "cbd"
version.workspace = true
edition.workspace = true
# The bundle owns both halves, so it forwards both feature sets
# (architecture/build-features.md D1). `cargo build -p cbd
# --no-default-features --features fs,opus` is a local-files-only bundle.
[features]
default = [
"all-providers",
"opus",
"opus-bundled",
"spectrum",
"web-ui",
"notifications",
"mpris",
]
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"]
rss = ["crabidy-server/rss"]
fs = ["crabidy-server/fs"]
opus = ["crabidy-server/opus"]
opus-bundled = ["crabidy-server/opus-bundled"]
spectrum = ["crabidy-server/spectrum"]
web-ui = ["crabidy-server/web-ui"]
notifications = ["cbd-tui/notifications"]
mpris = ["cbd-tui/mpris"]
[dependencies]
cbd-cli = { workspace = true, features = ["client"] }
cbd-tui = { workspace = true, default-features = false }
clap.workspace = true
crabidy-core.workspace = true
crabidy-server = { workspace = true, default-features = false }
dirs.workspace = true
tokio = { workspace = true, features = ["full"] }
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
# Default features only (clap-only) so asset generation stays cheap.
[build-dependencies]
cbd-cli.workspace = true
clap.workspace = true

View File

@ -1,23 +0,0 @@
//! Generates shell completions and a man page for `cbd` from its top-level
//! clap command (architecture/cli.md D7). Written into `OUT_DIR` every build,
//! and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli` is a
//! default-features (clap-only) build-dependency, so this never pulls tonic
//! into ordinary builds.
use std::path::Path;
fn main() {
use clap::CommandFactory;
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
let bin = "cbd";
let command = cbd_cli::CbdCli::command();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
}
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
}
}
}

View File

@ -1,285 +0,0 @@
//! `cbd`: server and TUI bundled into one binary
//! (see `architecture/cbd-bundle.md`).
//!
//! Starting `cbd` starts the crabidy server in-process, waits for it to
//! accept connections, and runs the TUI against it — the same configs,
//! the same localhost gRPC wire as the standalone pair. If a server is
//! already listening (a standalone `crabidy-server`), `cbd` adopts it
//! instead of failing. Quitting the TUI ends the process, and with it
//! the in-process server; the current queue is persisted continuously,
//! so the next start restores it.
use std::error::Error;
use std::sync::OnceLock;
use std::time::Duration;
use cbd_cli::{CbdCli, CbdCommand, RemoteCmd};
use cbd_tui::config::{self, Config};
use clap::{CommandFactory, Parser};
use crabidy_server::cli as server_cli;
use tracing::{info, warn};
/// The config file name for the bundled binary (separate from `cbd-tui.toml`).
const CONFIG_FILE: &str = "cbd.toml";
static CONFIG: OnceLock<Config> = OnceLock::new();
/// How long to wait for the server socket before giving up. Generous:
/// the first server start may run a provider login flow.
const READINESS_ATTEMPTS: u32 = 120;
const READINESS_DELAY: Duration = Duration::from_millis(500);
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// `cbd` is the union of the server and client command surfaces
// (architecture/cli.md D2): no subcommand runs the in-process server +
// TUI, exactly as before.
let cli = CbdCli::parse();
match cli.command {
None => run_bundle(cli).await,
Some(command) => {
if let Err(err) = run_command(&cli.remote, command).await {
eprintln!("error: {err}");
std::process::exit(1);
}
Ok(())
}
}
}
/// Runs the bundled server + TUI (the no-subcommand default), unchanged from
/// before save for the clap-based config load and flag overrides.
async fn run_bundle(cli: CbdCli) -> Result<(), Box<dyn Error>> {
// Both halves share one file-based subscriber: the terminal belongs
// to the TUI, so the server's usual stderr logging would corrupt it.
let _log_guard = init_tracing();
// Tracing is not the only writer, though. The audio stack runs *in this
// process* here, so ALSA's "underrun occurred" prints — and any panic
// message — would go straight onto the interface (cbd_tui::stderr).
let stderr_log = cbd_tui::stderr::log_dir().join("cbd.stderr.log");
if let Err(err) = cbd_tui::stderr::capture_into(&stderr_log) {
eprintln!(
"could not redirect stderr to {}: {err}",
stderr_log.display()
);
}
// `cbd` reads its OWN config (`cbd.toml`), separate from the
// standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on
// one machine — `cbd` self-contained against its in-process server,
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
// shared `address` would force one to follow the other. `cbd`
// defaults to localhost, which matches its embedded server.
let mut config = config::load_first_run(CONFIG_FILE);
config::apply_overrides(
&mut config,
cli.remote.address,
cli.remote.user,
cli.remote.password,
cli.spectrum,
);
let config = CONFIG.get_or_init(|| config);
let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
let mut server = tokio::spawn(crabidy_server::serve(addr));
wait_for_server(
&config.server.address,
&mut server,
READINESS_ATTEMPTS,
READINESS_DELAY,
)
.await?;
cbd_tui::run(config).await
}
/// Dispatches a `cbd` subcommand: `guard`/`scan` reuse the server logic,
/// `auth` writes `cbd.toml`, and `library`/`queue`/`global` run against a
/// server over gRPC.
async fn run_command(
remote: &cbd_cli::RemoteArgs,
command: CbdCommand,
) -> Result<(), Box<dyn Error>> {
match command {
CbdCommand::Guard(args) => server_cli::guard(args).await,
CbdCommand::Scan(args) => server_cli::scan(args).await,
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
CbdCommand::Features => server_cli::features(),
CbdCommand::Auth(args) => {
let password = args
.password
.ok_or("missing password (pass it as an argument)")?;
let path = config::write_auth(
CONFIG_FILE,
args.role.user_name(),
&password,
args.address.as_deref(),
)?;
println!(
"wrote credentials for {} to {}",
args.role.user_name(),
path.display()
);
Ok(())
}
CbdCommand::Library(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
}
CbdCommand::Queue(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
}
CbdCommand::Global(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
}
CbdCommand::Completions(args) => {
cbd_cli::print_completions(args.shell, &mut CbdCli::command(), "cbd");
Ok(())
}
}
}
/// Resolves the connection for a remote command: the CLI flags win over
/// `cbd.toml`, which supplies the fallback (address and credentials).
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
let config = config::load_first_run(CONFIG_FILE);
cbd_cli::Connection {
address: remote.address.clone().unwrap_or(config.server.address),
user: remote.user.clone().unwrap_or(config.server.user),
password: remote.password.clone().unwrap_or(config.server.password),
}
}
/// Waits until something accepts TCP connections on the TUI's configured
/// server address (scheme stripped): the in-process server coming up, or
/// an already-running standalone one (in which case our `serve` fails
/// with the port taken and is deliberately ignored). Fails when the
/// in-process server dies while nothing is listening, or after
/// `attempts` polls.
async fn wait_for_server(
address: &str,
server: &mut tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
attempts: u32,
delay: Duration,
) -> Result<(), Box<dyn Error>> {
let host_port = address
.trim_start_matches("http://")
.trim_start_matches("https://")
.trim_end_matches('/');
for _ in 0..attempts {
if tokio::net::TcpStream::connect(host_port).await.is_ok() {
if server.is_finished() {
warn!("a server is already listening; connecting to it instead");
} else {
info!(address, "server is ready");
}
return Ok(());
}
if server.is_finished() {
// Nothing listening and our server is gone: a real failure
// (provider init, bad address), not an occupied port.
return match server.await {
Ok(Ok(())) => Err("the server exited before becoming ready".into()),
Ok(Err(err)) => Err(err.to_string().into()),
Err(err) => Err(err.to_string().into()),
};
}
tokio::time::sleep(delay).await;
}
Err(format!("no server reachable at {host_port} after {attempts} attempts").into())
}
/// Logs to a file (`crabidy/cbd.log` in the state dir), like `cbd-tui` —
/// but with the server crates' filter, since they run in-process here.
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = cbd_tui::stderr::log_dir();
if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"could not create log directory {}: {err}",
log_dir.display()
);
return None;
}
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,cbd=debug,cbd_tui=debug,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug",
)
});
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true),
)
.init();
Some(guard)
}
#[cfg(test)]
mod tests {
use super::*;
/// A server task that never finishes, standing in for a healthy
/// in-process server still starting up.
fn pending_server() -> tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>> {
tokio::spawn(async {
std::future::pending::<()>().await;
Ok(())
})
}
#[tokio::test]
async fn readiness_polls_until_the_socket_accepts() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
// The scheme prefix must be stripped like the TUI config's URL.
let address = format!("http://{addr}/");
let mut server = pending_server();
wait_for_server(&address, &mut server, 10, Duration::from_millis(10))
.await
.expect("socket accepts");
server.abort();
}
#[tokio::test]
async fn readiness_gives_up_and_reports_a_dead_server() {
// Nothing listens on this address (bound, then dropped).
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
// A dead server task with nothing listening is a real failure.
let mut dead: tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>> =
tokio::spawn(async { Err("provider init failed".into()) });
let err = wait_for_server(
&format!("http://{addr}"),
&mut dead,
10,
Duration::from_millis(10),
)
.await
.expect_err("dead server surfaces");
assert!(err.to_string().contains("provider init failed"));
// A healthy-but-slow server just runs out of attempts.
let mut server = pending_server();
let err = wait_for_server(
&format!("http://{addr}"),
&mut server,
3,
Duration::from_millis(10),
)
.await
.expect_err("gives up eventually");
assert!(err.to_string().contains("after 3 attempts"));
server.abort();
}
}

View File

@ -1,36 +1,21 @@
[package]
name = "crabidy-core"
version.workspace = true
edition.workspace = true
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-trait.workspace = true
flume.workspace = true
percent-encoding.workspace = true
prost.workspace = true
serde.workspace = true
toml.workspace = true
# Codegen only: the generated client/server stubs need no transport,
# which keeps this crate building for wasm32 (cbd-web, see
# architecture/web-client.md). Native binaries pull the full tonic
# through their own dependency edges.
tonic = { workspace = true, default-features = false, features = ["codegen"] }
tracing.workspace = true
tonic-prost.workspace = true
# Config loading is native-only: the browser has no config directory
# (architecture/web-client.md).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap-serde-derive.workspace = true
dirs.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
async-trait = "0.1.68"
clap = "4.3.3"
clap-serde-derive = "0.2.0"
dirs = "5.0.1"
prost = "0.11"
serde = "1.0.163"
toml = "0.7.4"
tonic = "0.9"
[build-dependencies]
tonic-prost-build.workspace = true
# prost and tonic-prost are used by the code generated from the proto files,
# which cargo-machete cannot see.
[package.metadata.cargo-machete]
ignored = ["prost", "tonic-prost"]
async-trait = "0.1.68"
serde = "1.0.163"
tonic-build = "0.9"

View File

@ -1,10 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// No `connect()` convenience impl: it hardcodes tonic::transport,
// which the wasm build of this crate deliberately lacks
// (architecture/web-client.md). Clients construct their channel
// (native: Endpoint, browser: tonic-web-wasm-client) themselves.
tonic_prost_build::configure()
.build_transport(false)
.compile_protos(&["crabidy/v1/crabidy.proto"], &["."])?;
tonic_build::compile_protos("crabidy/v1/crabidy.proto")?;
Ok(())
}

View File

@ -7,32 +7,6 @@ service CrabidyService {
// Library
rpc GetLibraryNode(GetLibraryNodeRequest) returns (GetLibraryNodeResponse);
// Creates a child node under a creatable parent (LibraryNode.is_creatable).
// What creation means is provider-defined; under /tidal/search the title is
// a search term and the created node holds its results. Idempotent: an
// existing title returns the existing node.
rpc CreateLibraryNode(CreateLibraryNodeRequest) returns (CreateLibraryNodeResponse);
// Renames a node whose listing entry sets is_editable. For a search term
// the title is the query, so a rename re-runs the search; the node's path
// changes with the title. Renaming onto an existing sibling title merges
// with it. Returns the renamed node at its new path.
rpc RenameLibraryNode(RenameLibraryNodeRequest) returns (RenameLibraryNodeResponse);
// Deletes a node whose listing entry sets is_deletable, or a track
// whose parent node sets tracks_deletable. On filesystem-backed stores
// this removes the data from disk: a folder is deleted recursively, a
// track loses its metadata file and its local audio. Idempotent:
// deleting an already-gone node succeeds. Returns the refreshed parent.
rpc DeleteLibraryNode(DeleteLibraryNodeRequest) returns (DeleteLibraryNodeResponse);
// Captures the queueable subtree at `path` as the bookmark `name`: a
// structure-preserving snapshot under /bookmarks/<name> (folders per
// child node, link track files per track). Overwrites an existing
// bookmark of the same name; a *download* capture instead merges into an
// existing /captures/<name>, resuming what is not yet downloaded.
// Returns once the capture is accepted (name, store, and download
// blessing validated); the walk runs server-side and reports through
// CaptureProgress updates on GetUpdateStream, ending in one event with
// `finished` set (and `error` on failure).
rpc CaptureLibraryNode(CaptureLibraryNodeRequest) returns (CaptureLibraryNodeResponse);
// Queue
rpc Queue(QueueRequest) returns (QueueResponse);
@ -41,14 +15,6 @@ service CrabidyService {
rpc Remove(RemoveRequest) returns (RemoveResponse);
rpc Insert(InsertRequest) returns (InsertResponse);
rpc ClearQueue(ClearQueueRequest) returns (ClearQueueResponse);
// Drops duplicate entries, keeping one copy of each track. Never removes
// the playing entry, so it cannot interrupt playback. Answers with the
// number of entries removed (architecture/queue-order.md D2).
rpc DedupQueue(DedupQueueRequest) returns (DedupQueueResponse);
// Reorders the queue by one of the `QueueSort` strategies. The playing
// track keeps playing at its new position; the new order arrives on the
// update stream (architecture/queue-order.md).
rpc SortQueue(SortQueueRequest) returns (SortQueueResponse);
rpc SetCurrent(SetCurrentRequest) returns (SetCurrentResponse);
rpc ToggleShuffle(ToggleShuffleRequest) returns (ToggleShuffleResponse);
rpc ToggleRepeat(ToggleRepeatRequest) returns (ToggleRepeatResponse);
@ -63,11 +29,6 @@ service CrabidyService {
rpc Next(NextRequest) returns (NextResponse);
rpc Prev(PrevRequest) returns (PrevResponse);
rpc RestartTrack(RestartTrackRequest) returns (RestartTrackResponse);
// Moves the playing position inside the current track. Relative, never
// absolute: the server adds the offset to the live position, so repeated
// presses of a seek key compose instead of all computing from the same
// (already stale) position update.
rpc Seek(SeekRequest) returns (SeekResponse);
}
// System
@ -80,78 +41,29 @@ message InitResponse {
float volume = 5;
bool mute = 6;
TrackPosition position = 7;
// Whether the server has any credentials configured (the auth on/off
// switch). Reachable anonymously, so a client that connected as the
// unauthenticated fallback role can learn a higher role is available
// and offer a login (architecture/roles-auth.md).
bool auth_enabled = 8;
}
// Library
message GetLibraryNodeRequest {
string path = 1;
string uuid = 1;
}
message GetLibraryNodeResponse {
LibraryNode node = 1;
}
message CreateLibraryNodeRequest {
// Path of the creatable parent, e.g. /tidal/search.
string parent_path = 1;
// Human-entered name; becomes the node title. The path segment is a
// percent-encoded form of it, chosen by the provider.
string title = 2;
}
message CreateLibraryNodeResponse {
LibraryNode node = 1;
}
message RenameLibraryNodeRequest {
// Path of the node to rename.
string path = 1;
// New human-entered title; the provider derives the new path segment.
string new_title = 2;
}
message RenameLibraryNodeResponse {
// The renamed node, at its (possibly changed) path.
LibraryNode node = 1;
}
message DeleteLibraryNodeRequest {
// Path of the node to delete.
string path = 1;
}
message DeleteLibraryNodeResponse {
// The parent node with the deleted child gone what a client should
// display after the delete, without a follow-up GetLibraryNode.
LibraryNode parent = 1;
}
message CaptureLibraryNodeRequest {
// Path of the queueable node (or track) to capture.
string path = 1;
// Capture name; becomes the top-level folder under /bookmarks (or
// /captures when download is set).
string name = 2;
// Download every track's audio into the capture instead of writing
// link files; the source node must set is_downloadable.
bool download = 3;
}
message CaptureLibraryNodeResponse {}
// Queue
message QueueRequest {
repeated string paths = 1;
repeated string uuids = 1;
}
message QueueResponse {}
message ReplaceRequest {
repeated string paths = 1;
repeated string uuids = 1;
}
message ReplaceResponse {}
message AppendRequest {
repeated string paths = 1;
repeated string uuids = 1;
}
message AppendResponse {}
@ -161,11 +73,8 @@ message RemoveRequest {
message RemoveResponse {}
message InsertRequest {
// Index to insert **at**: the entry currently there, and everything after
// it, shifts down. 0 inserts at the front, a position at or past the end
// appends. "After entry N" is therefore N + 1.
uint32 position = 1;
repeated string paths = 2;
repeated string uuids = 2;
}
message InsertResponse {}
@ -190,59 +99,6 @@ message ClearQueueRequest {
}
message ClearQueueResponse {}
// De-duplication of the live queue (architecture/queue-order.md). Two
// entries are the same track when they carry the same `provider_item_id`
// under the same provider (the first path segment) or when that id is
// empty the same `path`. Deliberately *not* metadata matching: the same
// artist and title is routinely a different recording (D3).
message DedupQueueRequest {
// Collapse entries with the same artist and title instead the same song
// whatever the recording, so a remix, a radio edit and the album version
// reduce to one. Aggressive by construction: it discards versions the user
// may have queued deliberately, which is why it is opt-in and why the
// default above only merges what is provably the same item. The survivor is
// the playing entry, else the *longest* take (the full version rather than
// an edit), else the earliest.
bool by_title = 1;
}
message DedupQueueResponse {
// How many entries were dropped. 0 says the queue held no duplicates,
// which is not the same answer as "nothing happened" hence a response
// field where the other queue verbs have none (D2).
uint32 removed = 1;
}
// How `SortQueue` orders the queue.
//
// Every strategy is a *stable* sort, so entries with an equal key keep their
// queue order for a queued album that is its track order. Text compares
// case-insensitively and without collation; blank text and unknown durations
// sort last in **both** directions (architecture/queue-order.md D7, D8, D9).
enum QueueSort {
// A client that did not set the field. Rejected as invalid rather than
// silently treated as one of the strategies below.
QUEUE_SORT_UNSPECIFIED = 0;
// Artist, then album title; queue order within an album.
QUEUE_SORT_ARTIST = 1;
// Album title; queue order within an album.
QUEUE_SORT_ALBUM = 2;
// Track title.
QUEUE_SORT_TITLE = 3;
// Duration; tracks with no known duration (web radio) sort last.
QUEUE_SORT_DURATION = 4;
// Reverses the order the queue is currently in. Not a key: `descending`
// is ignored, since reversing descendingly is the same operation.
QUEUE_SORT_REVERSE = 5;
}
message SortQueueRequest {
QueueSort sort = 1;
// Reverses the comparison of `sort` (largest/last first). Ignored by
// `QUEUE_SORT_REVERSE`. Blanks and unknown durations still sort last.
bool descending = 2;
}
message SortQueueResponse {}
// Stream
message GetUpdateStreamRequest {}
message GetUpdateStreamResponse {
@ -254,43 +110,9 @@ message GetUpdateStreamResponse {
float volume = 5;
bool mute = 6;
TrackPosition position = 7;
CaptureProgress capture_progress = 8;
SpectrumFrame spectrum = 9;
}
}
// One frame of the audio frequency spectrum (architecture/spectrum.md).
// Broadcast by the server at a low frame rate while audio is playing; an
// all-zero frame signals silence. Clients render `bins` as bars; they do
// no signal processing themselves.
message SpectrumFrame {
// Normalized magnitudes in [0, 1], low frequency first, log-spaced.
// The count is the server's bin resolution (a handful of bars).
repeated float bins = 1;
}
// Progress of a running capture (CaptureLibraryNode). Broadcast after each
// processed track; exactly one event per capture sets `finished` (with
// `error` on failure).
message CaptureProgress {
// The capture (or bookmark) name the user chose.
string name = 1;
// True for a download capture (W), false for a bookmark (w).
bool download = 2;
// Tracks settled so far (reused, downloaded, linked, or recorded as
// skipped) reaches tracks_total on success.
uint32 tracks_done = 3;
// Total tracks discovered by the enumeration; 0 until it completes.
uint32 tracks_total = 4;
// Of the settled tracks, how many were recorded as skipped
// (uncapturable source) this run.
uint32 tracks_skipped = 5;
// Terminal event: the capture is over.
bool finished = 6;
// Why it failed; empty on success. Never carries URLs or file contents.
string error = 7;
}
// Playback
message TogglePlayRequest {}
message TogglePlayResponse {}
@ -315,40 +137,11 @@ message PrevResponse {}
message RestartTrackRequest {}
message RestartTrackResponse {}
message SeekRequest {
// Signed offset from the current position; negative seeks backwards.
// Milliseconds, like TrackPosition, so a client is free to pick any step.
//
// Clamped by the server: backwards saturates at the start of the track (it
// never steps into the previous one), and forwards stops just short of the
// end, so an overshooting seek lets the track finish and the queue advance.
// A track whose duration is unknown (a length-less stream) has no upper
// clamp and the decoder may refuse the seek.
//
// Fire-and-forget, like the other playback rpcs: the response says the
// command was accepted, not that the seek happened. A source that cannot
// seek at all (SoundCloud's HLS streams) leaves the position unchanged; the
// position updates on the stream remain the truth.
sint32 delta_millis = 1;
}
message SeekResponse {}
// Data types
message LibraryNodeChild {
string path = 1;
string uuid = 1;
string title = 2;
bool is_queable = 3;
// Children may be created under this node (see CreateLibraryNode).
bool is_creatable = 4;
// This node may be renamed (see RenameLibraryNode).
bool is_editable = 5;
// This node may be deleted (see DeleteLibraryNode).
bool is_deletable = 6;
// This node allows download captures (CaptureLibraryNode with download).
bool is_downloadable = 7;
// Every track and child node below this child is captured (fully local in
// the content store). Clients mark captured rows.
bool is_captured = 8;
}
message QueueModifiers {
@ -361,10 +154,6 @@ message Queue {
uint32 current_position = 2;
// Without album
repeated Track tracks = 3;
// True while the server is still resolving queued paths into tracks: more
// tracks will arrive in subsequent Queue updates. Clients may show a
// loading indicator until an update carries resolving = false.
bool resolving = 4;
}
message QueueTrack {
@ -392,43 +181,20 @@ message Album {
}
message Track {
// Full library path including provider
string path = 1;
// Including provider
string uuid = 1;
string artist = 2;
string title = 3;
optional uint32 duration = 4;
optional Album album = 5;
// The track has no playable audio (a capture recorded its source as
// uncapturable). Clients mark it; playback skips it.
bool is_skipped = 6;
// Provider-internal id that identifies this item inside its provider,
// independent of the path it was reached by (playlist, search, album).
// Set by the owning provider; empty when unknown. Keys the content store.
string provider_item_id = 7;
// The content store already holds this track (by provider id or, once
// captured, as a store-backed playable). Set at listing time; clients
// mark captured rows. See architecture/crabidy-store.md.
bool is_captured = 8;
}
message LibraryNode {
// Full library path including provider
string path = 1;
// Including provider
string uuid = 1;
string title = 2;
repeated LibraryNodeChild children = 3;
optional string parent = 4;
repeated Track tracks = 5;
bool is_queable = 6;
// Children may be created under this node (see CreateLibraryNode).
bool is_creatable = 7;
// This node allows download captures; its listed tracks inherit the
// flag (CaptureLibraryNode with download).
bool is_downloadable = 8;
// This node's listed tracks may be deleted (see DeleteLibraryNode)
// like is_downloadable, tracks inherit the node's flag.
bool tracks_deletable = 9;
// Every track and child node below this node is captured (fully local in
// the content store). Clients mark captured nodes. See
// architecture/crabidy-store.md.
bool is_captured = 10;
}

View File

@ -1,4 +1,3 @@
#[cfg(not(target_arch = "wasm32"))]
use std::{
fs::{create_dir_all, read_to_string, File},
io::Write,
@ -6,134 +5,30 @@ use std::{
};
use async_trait::async_trait;
#[cfg(not(target_arch = "wasm32"))]
pub use clap_serde_derive::{self, clap, serde, ClapSerde};
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto;
/// A media provider addressed like a file system.
///
/// Every node and track has a `/`-separated absolute path whose first
/// segment names the provider, e.g. `/tidal/playlists/<id>/<track-id>`.
/// The path encodes the position in the library tree: ancestors are
/// obtained by trimming trailing segments.
#[async_trait]
pub trait ProviderClient: std::fmt::Debug + Send + Sync {
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
where
Self: Sized;
fn settings(&self) -> String;
/// Whether the path addresses a single track (as opposed to a node).
fn is_track_path(&self, path: &str) -> bool;
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>;
fn get_lib_root(&self) -> LibraryNode;
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
/// Creates a child node under a creatable parent (`LibraryNode.is_creatable`).
///
/// What creation means is provider-defined; under `/tidal/search` the
/// `title` is a search term and the created node holds its results.
/// Idempotent: an existing title returns the existing node. Errors:
/// [`ProviderError::NotSupported`] when the parent is not creatable,
/// [`ProviderError::InvalidInput`] when the title is empty or
/// whitespace-only.
async fn create_lib_node(
&self,
parent_path: &str,
title: &str,
) -> Result<LibraryNode, ProviderError>;
/// Renames a node advertised as editable (`LibraryNodeChild.is_editable`).
///
/// For a search term the title is the query, so a rename re-runs the
/// search; the node's path changes with the title. Renaming onto an
/// existing sibling title merges with it (that node is returned).
/// Errors: [`ProviderError::NotSupported`] when the path is not
/// editable, [`ProviderError::InvalidInput`] when the new title is empty
/// or whitespace-only.
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<LibraryNode, ProviderError>;
/// Deletes a node advertised as deletable (`LibraryNodeChild.is_deletable`).
///
/// Idempotent: deleting an already-gone node succeeds. Returns the
/// refreshed parent node (what a client should display next). Errors:
/// [`ProviderError::NotSupported`] when the path is not deletable.
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
/// Streams the playable tracks under `path` into `chunk_tx`, in playback
/// order.
///
/// This is a local bounded channel used as a stream, and its delivery
/// semantics are the contract:
///
/// - Zero or more non-empty chunks are sent, in playback order.
/// - Resolution is finished when the **sender** is dropped (this method
/// returning). There is no end-of-stream marker.
/// - Dropping the **receiver** cancels resolution: the provider stops
/// fetching at the next send and returns `Ok`.
/// - An unreadable node inside the walk is skipped with a warning; only
/// a `path` that cannot be resolved at all is an `Err`.
///
/// A track path yields exactly one single-track chunk. The default
/// implementation walks the node's queueable descendants depth-first in
/// pre-order and emits one chunk per node holding tracks; providers
/// should override it when they can produce finer-grained chunks (e.g.
/// one per fetched page of a large collection).
async fn resolve_tracks_into(
&self,
path: &str,
chunk_tx: flume::Sender<Vec<Track>>,
) -> Result<(), ProviderError> {
if self.is_track_path(path) {
match self.get_metadata_for_track(path).await {
Ok(track) => {
let _ = chunk_tx.send_async(vec![track]).await;
}
Err(err) => tracing::warn!(path, "failed to resolve track: {err}"),
}
return Ok(());
}
// Depth-first pre-order so tracks arrive in listing order; children
// are pushed reversed because the worklist pops from the back.
let mut nodes_to_go = vec![path.to_string()];
let mut at_root = true;
while let Some(node_path) = nodes_to_go.pop() {
let node = match self.get_lib_node(&node_path).await {
Ok(node) => node,
Err(err) if at_root => return Err(err),
Err(err) => {
tracing::warn!(node = node_path, "skipping unreadable node: {err}");
continue;
}
};
at_root = false;
if !node.is_queable {
continue;
}
if !node.tracks.is_empty() && chunk_tx.send_async(node.tracks).await.is_err() {
// Receiver gone: the consumer cancelled, stop fetching.
return Ok(());
}
nodes_to_go.extend(node.children.into_iter().rev().map(|c| c.path));
}
Ok(())
}
async fn get_lib_node(&self, list_uuid: &str) -> Result<LibraryNode, ProviderError>;
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
#[derive(Clone, Debug, Hash)]
pub enum ProviderError {
Config(String),
UnknownUser,
CouldNotLogin,
FetchError,
MalformedPath,
/// The operation is not supported at this path (e.g. creating a node
/// under a parent that is not creatable).
NotSupported,
/// User-supplied input was rejected (e.g. an empty node title).
InvalidInput,
MalformedUuid,
InternalError,
Other,
}
@ -144,95 +39,25 @@ impl std::fmt::Display for ProviderError {
}
}
impl std::error::Error for ProviderError {}
/// The path of the global library root.
pub const ROOT_PATH: &str = "/";
/// Returns the parent path, or `None` when at the root.
///
/// `/tidal/playlists/abc` -> `/tidal/playlists` -> `/tidal` -> `/`.
pub fn parent_path(path: &str) -> Option<&str> {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
match trimmed.rfind('/') {
Some(0) => Some(ROOT_PATH),
Some(idx) => Some(&trimmed[..idx]),
None => None,
}
}
/// Appends a segment to a path.
pub fn join_path(base: &str, segment: &str) -> String {
let base = base.trim_end_matches('/');
format!("{base}/{segment}")
}
/// Splits a path into its segments: `/tidal/playlists/x` -> ["tidal", "playlists", "x"].
pub fn path_segments(path: &str) -> Vec<&str> {
path.split('/').filter(|s| !s.is_empty()).collect()
}
/// Percent-encodes arbitrary user text into a single path segment.
///
/// `/`, `%`, whitespace and every other character that would confuse
/// `path_segments` or a URL are escaped: `AC/DC` -> `AC%2FDC`. The result is
/// never empty for non-empty input and round-trips through
/// [`decode_segment`].
pub fn encode_segment(text: &str) -> String {
/// Everything except ASCII alphanumerics and `-`, `_`, `.`, `~` is
/// escaped — the URL "unreserved" set. Notably `/`, `%` and whitespace.
const SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
percent_encoding::utf8_percent_encode(text, SEGMENT).to_string()
}
/// Decodes a segment produced by [`encode_segment`] back to the original
/// text. Invalid or lone percent escapes decode lossily (the raw bytes are
/// kept) rather than erroring: paths come from clients and must not panic.
pub fn decode_segment(segment: &str) -> String {
percent_encoding::percent_decode_str(segment)
.decode_utf8_lossy()
.into_owned()
}
impl LibraryNode {
pub fn new() -> Self {
Self {
path: ROOT_PATH.to_string(),
uuid: "node:/".to_string(),
title: "/".to_string(),
children: Vec::new(),
parent: None,
tracks: Vec::new(),
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
}
impl LibraryNodeChild {
/// A regular, non-creatable, immutable child. Creatable/editable/
/// deletable/downloadable children (e.g. the search node and its
/// terms, or Tidal's queueable subtrees) set the capability flags
/// explicitly via struct update.
pub fn new(path: String, title: String, is_queable: bool) -> Self {
pub fn new(uuid: String, title: String, is_queable: bool) -> Self {
Self {
path,
uuid,
title,
is_queable,
is_creatable: false,
is_editable: false,
is_deletable: false,
is_downloadable: false,
is_captured: false,
}
}
}
@ -241,7 +66,6 @@ pub enum QueueError {
NotQueable,
}
#[cfg(not(target_arch = "wasm32"))]
pub fn init_config<T>(config_file_name: &str) -> T
where
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,
@ -270,333 +94,3 @@ where
}
T::default().merge_clap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parent_path_walks_up_to_root() {
assert_eq!(
parent_path("/tidal/playlists/abc"),
Some("/tidal/playlists")
);
assert_eq!(parent_path("/tidal/playlists"), Some("/tidal"));
assert_eq!(parent_path("/tidal"), Some("/"));
assert_eq!(parent_path("/"), None);
}
#[test]
fn join_path_appends_segments() {
assert_eq!(join_path("/", "tidal"), "/tidal");
assert_eq!(join_path("/tidal", "playlists"), "/tidal/playlists");
}
#[test]
fn path_segments_splits() {
assert_eq!(path_segments("/"), Vec::<&str>::new());
assert_eq!(
path_segments("/tidal/artists/1/2"),
vec!["tidal", "artists", "1", "2"]
);
}
#[test]
fn encode_segment_round_trips_arbitrary_text() {
for term in [
"AC/DC",
"100% wrong",
"Björk",
"hello world",
"a%2Fb",
"?!#&=",
] {
let encoded = encode_segment(term);
assert_eq!(decode_segment(&encoded), term, "round trip of {term:?}");
}
}
#[test]
fn encoded_segments_are_path_safe() {
for term in ["AC/DC", "a/b/c", "//", "term with spaces"] {
let encoded = encode_segment(term);
assert!(!encoded.is_empty());
assert!(!encoded.contains('/'), "{encoded:?} must be one segment");
// Joining under a parent yields exactly one extra segment.
let path = join_path("/tidal/search", &encoded);
assert_eq!(path_segments(&path).len(), 3, "path {path:?}");
assert_eq!(parent_path(&path), Some("/tidal/search"));
}
}
#[test]
fn decode_segment_is_lossy_not_panicky() {
// Invalid or truncated escapes must never panic — paths come from
// clients. Exact output is unspecified, only totality matters.
for bad in ["%", "%2", "%zz", "abc%", "%%25"] {
let _ = decode_segment(bad);
}
}
/// A scripted in-memory provider for exercising the default
/// `resolve_tracks_into` walk. Node lookups are recorded so tests can
/// assert what was (not) fetched.
#[derive(Debug, Default)]
struct FakeProvider {
nodes: std::collections::HashMap<String, Result<LibraryNode, ProviderError>>,
track_paths: Vec<String>,
fetched: std::sync::Mutex<Vec<String>>,
}
impl FakeProvider {
fn node(path: &str, tracks: &[&str], children: &[&str], is_queable: bool) -> LibraryNode {
LibraryNode {
path: path.to_string(),
title: path.to_string(),
children: children
.iter()
.map(|c| LibraryNodeChild::new(c.to_string(), c.to_string(), true))
.collect(),
parent: None,
tracks: tracks
.iter()
.map(|t| Track {
path: t.to_string(),
artist: "artist".to_string(),
title: t.to_string(),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
})
.collect(),
is_queable,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
fn fetched(&self) -> Vec<String> {
self.fetched.lock().map(|f| f.clone()).unwrap_or_default()
}
}
#[async_trait]
impl ProviderClient for FakeProvider {
async fn init(_: &str) -> Result<Self, ProviderError> {
Ok(Self::default())
}
fn settings(&self) -> String {
String::new()
}
fn is_track_path(&self, path: &str) -> bool {
self.track_paths.iter().any(|p| p == path)
}
async fn get_urls_for_track(&self, _: &str) -> Result<Vec<String>, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn get_metadata_for_track(&self, path: &str) -> Result<Track, ProviderError> {
Ok(Track {
path: path.to_string(),
artist: "artist".to_string(),
title: path.to_string(),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
})
}
fn get_lib_root(&self) -> LibraryNode {
LibraryNode::new()
}
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
if let Ok(mut fetched) = self.fetched.lock() {
fetched.push(path.to_string());
}
self.nodes
.get(path)
.cloned()
.unwrap_or(Err(ProviderError::MalformedPath))
}
async fn create_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn rename_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn delete_lib_node(&self, _: &str) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
}
/// Runs the default resolve against the fake and collects the chunks it
/// streamed. The channel is bounded but larger than any test tree, so
/// the resolve never blocks on a full buffer here.
async fn resolve_chunks(provider: &FakeProvider, path: &str) -> Vec<Vec<String>> {
let (chunk_tx, chunk_rx) = flume::bounded(32);
provider
.resolve_tracks_into(path, chunk_tx)
.await
.expect("resolve failed");
chunk_rx
.into_iter()
.map(|chunk| chunk.into_iter().map(|t| t.path).collect())
.collect()
}
#[tokio::test]
async fn default_resolve_streams_chunks_per_node_in_preorder() {
let mut provider = FakeProvider::default();
// artist -> [album1, album2], each album carries tracks; the artist
// node itself has none. Pre-order and *listing order*: album1's
// tracks must come before album2's (the old walk popped LIFO and
// reversed siblings).
provider.nodes.insert(
"/p/artist".into(),
Ok(FakeProvider::node(
"/p/artist",
&[],
&["/p/artist/al1", "/p/artist/al2"],
true,
)),
);
provider.nodes.insert(
"/p/artist/al1".into(),
Ok(FakeProvider::node(
"/p/artist/al1",
&["/p/artist/al1/t1", "/p/artist/al1/t2"],
&[],
true,
)),
);
provider.nodes.insert(
"/p/artist/al2".into(),
Ok(FakeProvider::node(
"/p/artist/al2",
&["/p/artist/al2/t3"],
&[],
true,
)),
);
let chunks = resolve_chunks(&provider, "/p/artist").await;
// One chunk per track-bearing node; the trackless artist node adds
// no empty chunk.
assert_eq!(
chunks,
vec![
vec!["/p/artist/al1/t1".to_string(), "/p/artist/al1/t2".into()],
vec!["/p/artist/al2/t3".to_string()],
]
);
}
#[tokio::test]
async fn default_resolve_yields_one_chunk_for_track_paths() {
let mut provider = FakeProvider::default();
provider.track_paths.push("/p/al/t9".into());
let chunks = resolve_chunks(&provider, "/p/al/t9").await;
assert_eq!(chunks, vec![vec!["/p/al/t9".to_string()]]);
assert!(
provider.fetched().is_empty(),
"a track path must not fetch nodes"
);
}
#[tokio::test]
async fn default_resolve_skips_unreadable_nodes_and_unqueable_subtrees() {
let mut provider = FakeProvider::default();
provider.nodes.insert(
"/p/root".into(),
Ok(FakeProvider::node(
"/p/root",
&["/p/root/t0"],
&["/p/root/broken", "/p/root/private", "/p/root/ok"],
true,
)),
);
provider
.nodes
.insert("/p/root/broken".into(), Err(ProviderError::FetchError));
provider.nodes.insert(
"/p/root/private".into(),
Ok(FakeProvider::node(
"/p/root/private",
&["/p/root/private/hidden"],
&[],
false,
)),
);
provider.nodes.insert(
"/p/root/ok".into(),
Ok(FakeProvider::node(
"/p/root/ok",
&["/p/root/ok/t1"],
&[],
true,
)),
);
let chunks = resolve_chunks(&provider, "/p/root").await;
// The broken sibling is skipped, the non-queueable subtree
// contributes nothing, the rest still resolves in order.
assert_eq!(
chunks,
vec![
vec!["/p/root/t0".to_string()],
vec!["/p/root/ok/t1".to_string()],
]
);
}
#[tokio::test]
async fn default_resolve_stops_fetching_once_the_receiver_is_gone() {
let mut provider = FakeProvider::default();
provider.nodes.insert(
"/p/a".into(),
Ok(FakeProvider::node(
"/p/a",
&["/p/a/t1"],
&["/p/a/b", "/p/a/c"],
true,
)),
);
provider.nodes.insert(
"/p/a/b".into(),
Ok(FakeProvider::node("/p/a/b", &["/p/a/b/t2"], &[], true)),
);
provider.nodes.insert(
"/p/a/c".into(),
Ok(FakeProvider::node("/p/a/c", &["/p/a/c/t3"], &[], true)),
);
let (chunk_tx, chunk_rx) = flume::bounded(32);
drop(chunk_rx);
// A dropped receiver is cancellation, not an error ...
provider
.resolve_tracks_into("/p/a", chunk_tx)
.await
.expect("cancellation must not be an error");
// ... and the walk stops fetching instead of draining the tree.
assert_eq!(provider.fetched(), vec!["/p/a".to_string()]);
}
#[tokio::test]
async fn default_resolve_errors_only_for_an_unresolvable_root() {
let provider = FakeProvider::default();
let (chunk_tx, _chunk_rx) = flume::bounded::<Vec<Track>>(1);
let result = provider.resolve_tracks_into("/p/unknown", chunk_tx).await;
assert!(result.is_err(), "an unreadable root path is an error");
}
#[test]
fn child_new_defaults_all_capability_flags_off() {
// Wire contract: plain children are immutable; providers opt into
// capabilities explicitly via struct update.
let child = LibraryNodeChild::new("/tidal/x".to_string(), "x".to_string(), true);
assert!(!child.is_creatable);
assert!(!child.is_editable);
assert!(!child.is_deletable);
}
}

View File

@ -1,114 +1,32 @@
[package]
name = "crabidy-server"
version.workspace = true
edition.workspace = true
version = "0.1.0"
edition = "2021"
[[bin]]
name = "crabidy-server"
path = "src/main.rs"
# Everything is on by default: a plain build is the full server. Tailor a
# smaller binary with `--no-default-features --features …`
# (architecture/build-features.md D1). Feature names are exactly the
# names in `crabidy-server.toml`'s `providers` list (D3), so one
# vocabulary covers the compile-time and the runtime switch.
[features]
default = ["all-providers", "opus", "opus-bundled", "spectrum", "web-ui"]
# Every provider this binary can mount.
all-providers = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"jamendo",
"rss",
"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"]
rss = ["dep:rssdy", "_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 libopus dependency entirely and
# `.opus` from what `scan` indexes (D6).
opus = ["audio-player/opus"]
# Compile the vendored libopus (needs cmake) instead of linking the system
# one. On by default so a plain `cargo build` needs nothing installed; a
# distribution build drops it and links libopus from the system (D12).
opus-bundled = ["audio-player/opus-bundled"]
# 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"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow.workspace = true
argon2.workspace = true
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
blake3 = { workspace = true, optional = true }
clap.workspace = true
http.workspace = true
include_dir = { workspace = true, optional = true }
realfft = { workspace = true, optional = true }
tonic-web = { workspace = true, optional = true }
tower.workspace = true
# default-features = false so the server's own `opus` feature decides
# whether the libopus decoder is linked (D1).
audio-player = { workspace = true, default-features = false }
# The `client` feature pulls in the gRPC executor used by the
# library/queue/global subcommands (architecture/cli.md D1/D3).
cbd-cli = { workspace = true, features = ["client"] }
crabidy-core.workspace = true
dirs.workspace = true
flume.workspace = true
absdy = { workspace = true, optional = true }
fsdy = { workspace = true, optional = true }
fyyd = { workspace = true, optional = true }
jamendody = { workspace = true, optional = true }
rssdy = { workspace = true, optional = true }
futures.workspace = true
rand.workspace = true
reqwest = { workspace = true, optional = true }
serde.workspace = true
soundclouddy = { workspace = true, optional = true }
thiserror.workspace = true
tidaldy = { workspace = true, optional = true }
tokio = { workspace = true, features = ["full"] }
toml.workspace = true
tokio-stream = { workspace = true, features = ["sync"] }
tonic = { workspace = true, features = ["router", "transport", "codegen"] }
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
ytdy = { workspace = true, optional = true }
[dev-dependencies]
argon2.workspace = true
base64.workspace = true
http.workspace = true
tempfile.workspace = true
tower.workspace = true
# Default features only (clap-only): generating completions and a man page
# must not drag tonic into ordinary builds (architecture/cli.md D7).
[build-dependencies]
cbd-cli.workspace = true
clap.workspace = true
anyhow = "1.0.71"
tokio = { version = "1.28.0", features = ["full"] }
tidaldy = { path = "../tidaldy" }
crabidy-core = { path = "../crabidy-core" }
audio-player = { path = "../audio-player" }
once_cell = "1.17.1"
serde_json = "1.0.96"
serde = "1.0.163"
flume = "0.10.14"
tonic = "0.9.2"
async-trait = "0.1.68"
futures = "0.3.28"
tokio-stream = { version = "0.1.14", features = ["sync"] }
dirs = "5.0.1"
tracing = "0.1.37"
tracing-subscriber = "0.3.17"
tracing-appender = "0.2.2"
tracing-log = "0.1.3"
log = "0.4.18"
rand = "0.8.5"

View File

@ -1,85 +0,0 @@
//! Stages the web client bundle for embedding (feature `web-ui`,
//! architecture/web-client.md): copies `cbd-web/dist` (the trunk
//! output) into `OUT_DIR/webdist`, or generates a placeholder page
//! when the bundle has not been built — a plain `cargo build` must
//! neither fail nor require the wasm toolchain. Deliberately no
//! cargo-in-cargo: this never invokes trunk itself.
use std::path::Path;
fn main() {
generate_cli_assets();
stage_web_bundle();
}
/// Writes shell completions and a man page for `crabidy-server` into `OUT_DIR`
/// every build, and additionally into `$CBD_ASSET_DIR` when set
/// (architecture/cli.md D7). `cbd-cli` is a default-features (clap-only)
/// build-dependency, so this never pulls tonic into ordinary builds.
fn generate_cli_assets() {
use clap::CommandFactory;
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
let bin = "crabidy-server";
let command = cbd_cli::ServerCli::command();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
}
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
}
}
}
/// Stages the web client bundle for embedding (feature `web-ui`).
fn stage_web_bundle() {
// Rerun when the bundle changes (or appears).
println!("cargo:rerun-if-changed=../cbd-web/dist");
if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() {
return;
}
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
let staged = Path::new(&out_dir).join("webdist");
// Start fresh so removed assets do not linger across builds.
if staged.exists() {
std::fs::remove_dir_all(&staged).expect("clean staged webdist");
}
std::fs::create_dir_all(&staged).expect("create staged webdist");
let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../cbd-web/dist");
if dist.join("index.html").is_file() {
copy_dir(&dist, &staged);
} else {
println!(
"cargo:warning=cbd-web/dist not found - embedding a placeholder web UI \
(build the bundle with: devenv shell -- build-web)"
);
std::fs::write(
staged.join("index.html"),
"<!doctype html><meta charset=\"utf-8\"><title>crabidy</title>\
<body style=\"font:16px system-ui;padding:2rem\">\
<h1>crabidy web UI not built</h1>\
<p>This server binary was compiled without the web bundle. \
Build it with <code>devenv shell -- build-web</code> and \
rebuild the server.</p>",
)
.expect("write placeholder index.html");
}
}
/// Copies `from` into `to` recursively (regular files only — the trunk
/// output contains nothing else).
fn copy_dir(from: &Path, to: &Path) {
for entry in std::fs::read_dir(from).expect("read dist dir") {
let entry = entry.expect("dist dir entry");
let target = to.join(entry.file_name());
let path = entry.path();
if path.is_dir() {
std::fs::create_dir_all(&target).expect("create staged subdir");
copy_dir(&path, &target);
} else {
std::fs::copy(&path, &target).expect("copy dist file");
}
}
}

View File

@ -1,640 +0,0 @@
//! Role-based authorization for the gRPC surface
//! (architecture/roles-auth.md).
//!
//! Enforcement lives in exactly one place: [`AuthLayer`], a tower layer
//! in front of the tonic service. It authenticates the HTTP basic-auth
//! header against the configured role hashes and checks the resulting
//! [`Role`] against the method's [`minimum_role`] — *before* any
//! handler runs, default-deny for methods it does not know. Handlers
//! never see unauthorized requests and did not change for this feature.
//!
//! Credentials never appear in logs or error messages.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use base64::Engine;
use futures::future::{ready, Either, Ready};
use tonic::Status;
use tracing::warn;
use crate::settings::AuthSettings;
/// The three roles, ordered by privilege: every role includes the
/// rights of the roles below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Role {
/// May read and append tracks to the queue (plus create search
/// terms — the mechanism of finding something to append).
QueueAppender,
/// Anything on the queue and playback, but no library writes.
QueueOwner,
/// The normal user: everything.
Owner,
}
impl Role {
/// The basic-auth user name selecting this role.
fn from_user(user: &str) -> Option<Role> {
match user {
"owner" => Some(Role::Owner),
"queue-owner" => Some(Role::QueueOwner),
"queue-appender" => Some(Role::QueueAppender),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::QueueOwner => "queue-owner",
Role::QueueAppender => "queue-appender",
}
}
}
/// The gRPC path prefix of our service's methods.
const SERVICE_PREFIX: &str = "/crabidy.v1.CrabidyService/";
/// Minimum role required for a gRPC request path (the rights matrix,
/// architecture/roles-auth.md). Unknown methods — including anything
/// outside our service — require [`Role::Owner`]: fail-closed, a
/// future RPC starts locked until it is mapped here (a test pins the
/// full method list, so forgetting fails the suite).
pub fn minimum_role(grpc_path: &str) -> Role {
let Some(method) = grpc_path.strip_prefix(SERVICE_PREFIX) else {
return Role::Owner;
};
match method {
// Reads, the one appender queue verb, and search-term creation.
"Init" | "GetLibraryNode" | "GetUpdateStream" | "Append" | "CreateLibraryNode" => {
Role::QueueAppender
}
// Every other queue and playback verb.
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "DedupQueue" | "SortQueue"
| "SetCurrent" | "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop"
| "ChangeVolume" | "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => {
Role::QueueOwner
}
// Library writes (CaptureLibraryNode, SaveQueue,
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
_ => Role::Owner,
}
}
/// Hashes a password into the PHC string `crabidy-server.toml` expects
/// (argon2id, default parameters, fresh random salt). Backs the
/// `crabidy-server guard` subcommand.
pub fn hash_password(password: &str) -> Result<String, String> {
use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::PasswordHasher;
Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
// The error is parameter trouble, never the password itself.
.map(|hash| hash.to_string())
.map_err(|err| format!("cannot hash password: {err}"))
}
/// Verifies basic-auth credentials against the configured role hashes.
///
/// Argon2 verification is deliberately slow, so *successful* header
/// values are cached (value → role); the cache is fed only by
/// successes, bounding it by the number of valid credentials. Failures
/// re-verify every time, which doubles as throttling.
pub struct Authenticator {
/// `(role, PHC hash)` pairs from the config; empty = auth off.
hashes: Vec<(Role, String)>,
/// The role granted to requests that carry no credentials: the most
/// privileged role left *unguarded* (no hash in the config), or
/// `None` when every role is guarded and anonymous access is denied.
/// Guarding runs from the top down (`AuthSettings::validate`), so
/// this is simply the first role, owner → queue-owner →
/// queue-appender, whose hash is absent.
unauthenticated: Option<Role>,
verified: RwLock<HashMap<String, Role>>,
}
impl Authenticator {
pub fn new(settings: &AuthSettings) -> Self {
let mut hashes = Vec::new();
for (role, hash) in [
(Role::Owner, &settings.owner),
(Role::QueueOwner, &settings.queue_owner),
(Role::QueueAppender, &settings.queue_appender),
] {
if let Some(hash) = hash {
// Reject unusable hashes at startup, when the operator
// is looking — not at the first login attempt.
if let Err(err) = PasswordHash::new(hash) {
warn!(
role = role.name(),
"unusable password hash in config: {err}"
);
} else {
hashes.push((role, hash.clone()));
}
}
}
// Anonymous callers inherit the highest role that is *not*
// guarded. A hash that is present but unusable still counts as
// guarded here (fail-closed): that level is neither reachable by
// login nor handed to anonymous callers.
let unauthenticated = if settings.owner.is_none() {
Some(Role::Owner)
} else if settings.queue_owner.is_none() {
Some(Role::QueueOwner)
} else if settings.queue_appender.is_none() {
Some(Role::QueueAppender)
} else {
None
};
Self {
hashes,
unauthenticated,
verified: RwLock::new(HashMap::new()),
}
}
/// Whether any credential is configured (the auth on/off switch).
pub fn enabled(&self) -> bool {
!self.hashes.is_empty()
}
/// The role an anonymous (headerless) request receives — the most
/// privileged unguarded role, or `None` when every role is guarded.
pub fn unauthenticated_role(&self) -> Option<Role> {
self.unauthenticated
}
/// Resolves the request's `authorization` header value to a role.
///
/// A request with **no** `authorization` header is anonymous and
/// receives [`Authenticator::unauthenticated_role`] — the most
/// privileged unguarded role — or is denied when every role is
/// guarded. A request that **does** carry a header is attempting to
/// authenticate; every way that can fail — wrong scheme, broken
/// base64, unknown user, wrong password — answers the same
/// `UNAUTHENTICATED` so callers cannot probe which part was wrong.
/// It never silently falls back to the anonymous role. Never panics
/// on input.
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
let denied = || Status::unauthenticated("credentials required");
let Some(header) = header else {
return self.unauthenticated.ok_or_else(denied);
};
if let Some(role) = self
.verified
.read()
.ok()
.and_then(|cache| cache.get(header).copied())
{
return Ok(role);
}
let encoded = header
.strip_prefix("Basic ")
.or_else(|| header.strip_prefix("basic "))
.ok_or_else(denied)?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.map_err(|_| denied())?;
let decoded = String::from_utf8(decoded).map_err(|_| denied())?;
let (user, password) = decoded.split_once(':').ok_or_else(denied)?;
let role = Role::from_user(user).ok_or_else(denied)?;
let hash = self
.hashes
.iter()
.find(|(r, _)| *r == role)
.map(|(_, h)| h)
.ok_or_else(denied)?;
// Validated in `new`; a parse failure here is unreachable but
// must still deny, not panic.
let parsed = PasswordHash::new(hash).map_err(|_| denied())?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| denied())?;
if let Ok(mut cache) = self.verified.write() {
cache.insert(header.to_string(), role);
}
Ok(role)
}
#[cfg(test)]
fn cached(&self) -> usize {
self.verified.read().map(|c| c.len()).unwrap_or(0)
}
}
/// Tower layer installing [`AuthService`] in front of the tonic
/// service.
#[derive(Clone)]
pub struct AuthLayer {
auth: Arc<Authenticator>,
}
impl AuthLayer {
pub fn new(auth: Arc<Authenticator>) -> Self {
Self { auth }
}
}
impl<S> tower::Layer<S> for AuthLayer {
type Service = AuthService<S>;
fn layer(&self, inner: S) -> Self::Service {
AuthService {
inner,
auth: self.auth.clone(),
}
}
}
/// The single authorization gate: authenticates the header, compares
/// the role against [`minimum_role`] of the request path, and either
/// forwards to the inner service or answers a trailers-only gRPC error
/// (`UNAUTHENTICATED` / `PERMISSION_DENIED`) without running any
/// handler.
#[derive(Clone)]
pub struct AuthService<S> {
inner: S,
auth: Arc<Authenticator>,
}
impl<S, ReqBody, ResBody> tower::Service<http::Request<ReqBody>> for AuthService<S>
where
S: tower::Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
ResBody: Default,
{
type Response = S::Response;
type Error = S::Error;
type Future = Either<S::Future, Ready<Result<Self::Response, Self::Error>>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: http::Request<ReqBody>) -> Self::Future {
// The header value itself is a secret and is never logged.
let header = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
let decision = self.auth.authenticate(header).and_then(|role| {
let needed = minimum_role(req.uri().path());
if role >= needed {
Ok(())
} else {
Err(Status::permission_denied(format!(
"requires the {} role",
needed.name()
)))
}
});
match decision {
Ok(()) => Either::Left(self.inner.call(req)),
Err(status) => Either::Right(ready(Ok(status.into_http()))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::PasswordHasher;
/// A PHC hash of `password` with cheap test parameters (the params
/// travel inside the PHC string, so default verification reads
/// them back).
fn hash(password: &str) -> String {
let params = argon2::Params::new(8, 1, 1, None).expect("params");
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
argon2
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.expect("hash")
.to_string()
}
fn basic(user: &str, password: &str) -> String {
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
format!("Basic {encoded}")
}
fn authenticator() -> Authenticator {
Authenticator::new(&AuthSettings {
owner: Some(hash("os")),
queue_owner: Some(hash("qos")),
queue_appender: None,
})
}
// ---- the rights matrix -------------------------------------------
#[test]
fn the_method_table_pins_every_rpc_of_the_service() {
let appender = [
"Init",
"GetLibraryNode",
"GetUpdateStream",
"Append",
"CreateLibraryNode",
];
let queue_owner = [
"Queue",
"Replace",
"Remove",
"Insert",
"ClearQueue",
"DedupQueue",
"SortQueue",
"SetCurrent",
"ToggleShuffle",
"ToggleRepeat",
"TogglePlay",
"Stop",
"ChangeVolume",
"ToggleMute",
"Next",
"Prev",
"RestartTrack",
"Seek",
];
let owner = [
"CaptureLibraryNode",
"SaveQueue",
"RenameLibraryNode",
"DeleteLibraryNode",
];
// Every RPC must appear in exactly one list above. Taken from the
// proto itself rather than a count copied out of it: a hardcoded
// total let `Seek` be added to the service and land on the
// owner-only fallthrough with the suite still green, which is the
// one thing this test exists to prevent.
let proto = include_str!("../../crabidy-core/crabidy/v1/crabidy.proto");
let service: std::collections::BTreeSet<&str> = proto
.lines()
.filter_map(|line| line.trim().strip_prefix("rpc "))
.filter_map(|rest| rest.split('(').next())
.map(str::trim)
.collect();
let mapped: std::collections::BTreeSet<&str> = appender
.iter()
.chain(queue_owner.iter())
.chain(owner.iter())
.copied()
.collect();
assert_eq!(
mapped, service,
"every RPC of the service needs a role (left: mapped, right: the proto)"
);
for method in appender {
let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}");
}
for method in queue_owner {
let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::QueueOwner, "{method}");
}
for method in owner {
let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::Owner, "{method}");
}
}
#[test]
fn unknown_methods_and_foreign_services_are_owner_only() {
assert_eq!(
minimum_role("/crabidy.v1.CrabidyService/BrandNewRpc"),
Role::Owner
);
assert_eq!(minimum_role("/grpc.health.v1.Health/Check"), Role::Owner);
assert_eq!(minimum_role("nonsense"), Role::Owner);
}
#[test]
fn roles_are_ordered_by_privilege() {
assert!(Role::Owner > Role::QueueOwner);
assert!(Role::QueueOwner > Role::QueueAppender);
}
// ---- authentication ----------------------------------------------
#[test]
fn without_configured_hashes_everyone_is_owner() {
let auth = Authenticator::new(&AuthSettings::default());
assert!(!auth.enabled());
assert_eq!(auth.unauthenticated_role(), Some(Role::Owner));
assert_eq!(auth.authenticate(None).expect("open"), Role::Owner);
}
#[test]
fn the_unauthenticated_role_is_the_highest_unguarded_one() {
let owner = hash("o");
let qo = hash("qo");
let qa = hash("qa");
// Nothing guarded → owner. Owner guarded → queue-owner. Owner +
// queue-owner guarded → queue-appender. All guarded → denied.
let cases = [
(None, None, None, Some(Role::Owner)),
(Some(&owner), None, None, Some(Role::QueueOwner)),
(Some(&owner), Some(&qo), None, Some(Role::QueueAppender)),
(Some(&owner), Some(&qo), Some(&qa), None),
];
for (owner, queue_owner, queue_appender, expected) in cases {
let auth = Authenticator::new(&AuthSettings {
owner: owner.cloned(),
queue_owner: queue_owner.cloned(),
queue_appender: queue_appender.cloned(),
});
assert_eq!(auth.unauthenticated_role(), expected);
match expected {
Some(role) => assert_eq!(auth.authenticate(None).expect("anon"), role),
None => {
let err = auth.authenticate(None).expect_err("locked");
assert_eq!(err.code(), tonic::Code::Unauthenticated);
}
}
}
}
#[test]
fn a_header_carrying_bad_credentials_never_falls_back_to_anonymous() {
// owner guarded, so anonymous callers are queue-owner — but a
// present-yet-wrong owner login is denied, not downgraded.
let auth = Authenticator::new(&AuthSettings {
owner: Some(hash("os")),
queue_owner: None,
queue_appender: None,
});
assert_eq!(auth.unauthenticated_role(), Some(Role::QueueOwner));
let err = auth
.authenticate(Some(&basic("owner", "wrong")))
.expect_err("wrong password denied");
assert_eq!(err.code(), tonic::Code::Unauthenticated);
}
#[test]
fn valid_credentials_resolve_their_role() {
let auth = authenticator();
assert_eq!(
auth.authenticate(Some(&basic("owner", "os")))
.expect("owner"),
Role::Owner
);
assert_eq!(
auth.authenticate(Some(&basic("queue-owner", "qos")))
.expect("queue owner"),
Role::QueueOwner
);
}
#[test]
fn every_failure_is_the_same_unauthenticated() {
let auth = authenticator();
// A missing header is *not* a failure here — it yields the
// anonymous role. Only present-but-bad headers are failures, and
// they must be indistinguishable.
let cases: Vec<Option<String>> = vec![
Some("Bearer token".to_string()), // wrong scheme
Some("Basic !!!not-base64!!!".to_string()), // broken base64
Some("Basic bm9jb2xvbg==".to_string()), // no colon
Some(basic("owner", "wrong")), // wrong password
Some(basic("dj", "os")), // unknown user
Some(basic("queue-appender", "anything")), // role without hash
];
let mut messages = Vec::new();
for case in &cases {
let err = auth
.authenticate(case.as_deref())
.expect_err(&format!("{case:?}"));
assert_eq!(err.code(), tonic::Code::Unauthenticated, "{case:?}");
messages.push(err.message().to_string());
}
assert!(
messages.windows(2).all(|w| w[0] == w[1]),
"failures must be indistinguishable"
);
assert_eq!(auth.cached(), 0, "failures are never cached");
}
#[test]
fn hash_password_output_round_trips_through_the_authenticator() {
let phc = hash_password("hunter2").expect("hash");
assert!(phc.starts_with("$argon2id$"), "PHC format");
let auth = Authenticator::new(&AuthSettings {
owner: Some(phc),
queue_owner: None,
queue_appender: None,
});
assert_eq!(
auth.authenticate(Some(&basic("owner", "hunter2")))
.expect("round trip"),
Role::Owner
);
}
#[test]
fn successful_credentials_are_cached() {
let auth = authenticator();
let header = basic("owner", "os");
assert_eq!(auth.cached(), 0);
auth.authenticate(Some(&header)).expect("first");
assert_eq!(auth.cached(), 1);
auth.authenticate(Some(&header)).expect("cached");
assert_eq!(auth.cached(), 1, "same credential, one entry");
}
// ---- the layer -----------------------------------------------------
/// Calls the layered service once and returns the response plus
/// whether the inner service ran.
fn call_layer(
auth: Arc<Authenticator>,
path: &str,
header: Option<&str>,
) -> (http::Response<String>, bool) {
use std::sync::atomic::{AtomicBool, Ordering};
use tower::{Layer, Service, ServiceExt};
let reached = Arc::new(AtomicBool::new(false));
let flag = reached.clone();
let inner = tower::service_fn(move |_req: http::Request<()>| {
flag.store(true, Ordering::SeqCst);
ready(Ok::<_, std::convert::Infallible>(http::Response::new(
"handled".to_string(),
)))
});
let mut service = AuthLayer::new(auth).layer(inner);
let mut req = http::Request::new(());
*req.uri_mut() = path.parse().expect("uri");
if let Some(header) = header {
req.headers_mut().insert(
http::header::AUTHORIZATION,
header.parse().expect("header value"),
);
}
let response = futures::executor::block_on(async {
service.ready().await.expect("ready").call(req).await
})
.expect("call");
(response, reached.load(Ordering::SeqCst))
}
fn grpc_status(response: &http::Response<String>) -> Option<&str> {
response
.headers()
.get("grpc-status")
.and_then(|v| v.to_str().ok())
}
#[test]
fn the_layer_forwards_authorized_requests_only() {
// owner + queue-owner guarded, appender open → anonymous callers
// are queue-appenders.
let auth = Arc::new(authenticator());
let append = "http://s/crabidy.v1.CrabidyService/Append";
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
// No credentials: the anonymous role (queue-appender) is enough
// for Append, so the handler runs.
let (response, reached) = call_layer(auth.clone(), append, None);
assert!(reached);
assert_eq!(response.body(), "handled");
// No credentials against an owner-only method: the anonymous role
// is insufficient, denied before the handler runs.
let (response, reached) = call_layer(auth.clone(), capture, None);
assert!(!reached);
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
// Sufficient role via credentials: forwarded.
let (response, reached) = call_layer(auth.clone(), capture, Some(&basic("owner", "os")));
assert!(reached);
assert_eq!(response.body(), "handled");
// Valid credentials, insufficient role: denied, handler never
// runs, and the code distinguishes authorization from
// authentication.
let (response, reached) =
call_layer(auth.clone(), capture, Some(&basic("queue-owner", "qos")));
assert!(!reached);
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
// Fully locked (every role guarded): an anonymous request is
// denied with UNAUTHENTICATED before any handler runs.
let locked = Arc::new(Authenticator::new(&AuthSettings {
owner: Some(hash("os")),
queue_owner: Some(hash("qos")),
queue_appender: Some(hash("aps")),
}));
let (response, reached) = call_layer(locked, append, None);
assert!(!reached);
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
// Auth disabled: everything forwards without a header.
let open = Arc::new(Authenticator::new(&AuthSettings::default()));
let (_, reached) = call_layer(open, capture, None);
assert!(reached);
}
}

View File

@ -1,546 +0,0 @@
//! Capture primitives shared by the `/crabidy` store
//! (see `architecture/crabidy-store.md` D4).
//!
//! [`enumerate`] walks a library subtree into a flat list of tracks (each
//! with its target directory and listing index), enforcing the size
//! [`Caps`]; [`Downloader::download_to`] streams one stream URL into a file
//! with the player's bounded windowing; [`Progress`] reports a capture's
//! settling counters to clients. The `CrabidyStore` (`crabidy_store.rs`)
//! drives these into the content store, de-duplicating per track and
//! swapping the finished save into place.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
use crabidy_core::ProviderClient;
/// Connect timeout for download requests.
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Bytes per download request window. Some CDNs (googlevideo) reject
/// plain and open-ended requests from unattested clients with 403 and
/// only serve bounded ranges around this size — the same windowing the
/// player uses (audio-player/src/windowed_http.rs). Servers that ignore
/// the `Range` header answer 200 with the whole body, which is handled
/// as a single window.
pub const DOWNLOAD_WINDOW: u64 = 1024 * 1024;
/// Total per-track deadline: URL fetch, request, and streaming the whole
/// body. A stalled transfer aborts the capture instead of hanging it.
/// Generous: tokenless YouTube URLs are throttled to ~32 KB/s, so a long
/// track legitimately takes many minutes.
pub const DOWNLOAD_TRACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1800);
/// Size limits for one capture. The walk aborts with
/// [`CaptureError::TooLarge`] when a limit trips — a runaway provider tree
/// or oversized stream must not fill the disk.
#[derive(Debug, Clone, Copy)]
pub struct Caps {
/// Maximum directories (the capture root counts as the first).
pub max_dirs: usize,
/// Maximum track files.
pub max_tracks: usize,
/// Maximum total bytes downloaded in one run (ignored by link saves).
/// De-duplicated tracks (provider-id or hash hits) do not count.
pub max_bytes: u64,
}
/// Caps for bookmark (link) captures: link files are tiny, so only the
/// tree size is bounded.
pub const BOOKMARK_CAPS: Caps = Caps {
max_dirs: 1_000,
max_tracks: 20_000,
max_bytes: u64::MAX,
};
/// Caps for download captures: fewer tracks and a 4 GiB byte budget.
pub const DOWNLOAD_CAPS: Caps = Caps {
max_dirs: 1_000,
max_tracks: 500,
max_bytes: 4 * 1024 * 1024 * 1024,
};
/// Errors from validating or writing a capture.
///
/// At the RPC boundary: `InvalidName`/`BadSource` → `invalid_argument`,
/// `TooLarge`/`Disabled`/`Unsupported` → `failed_precondition`, the rest →
/// `internal`. Messages carry names, paths, and counts, never file
/// contents or stream URLs.
#[derive(Debug, thiserror::Error)]
pub enum CaptureError {
#[error("invalid name: {0}")]
InvalidName(&'static str),
#[error("the store is disabled")]
Disabled,
#[error("the source path cannot be captured: {0}")]
BadSource(String),
#[error("a save named \"{0}\" already exists")]
Conflict(String),
#[error("the source does not allow downloads")]
Unsupported,
#[error("the subtree is too large to capture ({0})")]
TooLarge(&'static str),
#[error("download failed: {0}")]
Download(String),
#[error("cannot write capture: {0}")]
Io(#[from] std::io::Error),
#[error(transparent)]
TrackFile(#[from] fsdy::TrackFileError),
#[error(transparent)]
Store(#[from] crate::crabidy_store::StoreError),
}
/// Progress reporting for one capture run
/// (`architecture/incremental-captures.md` D4).
///
/// The walk bumps the counters as it settles tracks; every bump publishes a
/// non-terminal [`CaptureProgress`] snapshot to the (bounded) channel,
/// **lossily** — a full channel drops the snapshot, never blocks the walk.
/// The terminal event is sent exactly once via [`Self::finish`] and is not
/// lossy. A [`Self::silent`] reporter counts without a channel.
#[derive(Debug)]
pub struct Progress {
name: String,
download: bool,
tx: Option<flume::Sender<CaptureProgress>>,
done: AtomicU32,
total: AtomicU32,
skipped: AtomicU32,
}
impl Progress {
/// A reporter publishing to `tx`.
pub fn new(name: &str, download: bool, tx: flume::Sender<CaptureProgress>) -> Self {
Self {
tx: Some(tx),
..Self::silent(name, download)
}
}
/// A reporter that only counts (tests, callers without a stream).
pub fn silent(name: &str, download: bool) -> Self {
Self {
name: name.to_string(),
download,
tx: None,
done: AtomicU32::new(0),
total: AtomicU32::new(0),
skipped: AtomicU32::new(0),
}
}
/// The wire snapshot of the current counters.
fn snapshot(&self, finished: bool, error: String) -> CaptureProgress {
CaptureProgress {
name: self.name.clone(),
download: self.download,
tracks_done: self.done.load(Ordering::Relaxed),
tracks_total: self.total.load(Ordering::Relaxed),
tracks_skipped: self.skipped.load(Ordering::Relaxed),
finished,
error,
}
}
/// Publishes a non-terminal snapshot; lossy on a full channel.
fn publish(&self) {
if let Some(tx) = &self.tx {
let _ = tx.try_send(self.snapshot(false, String::new()));
}
}
pub fn set_total(&self, total: usize) {
self.total
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
self.publish();
}
/// One track settled with playable data (reused, downloaded, linked).
pub fn track_done(&self) {
self.done.fetch_add(1, Ordering::Relaxed);
self.publish();
}
/// One track settled as skipped (counts toward done — the ratio must
/// reach total on success).
pub fn track_skipped(&self) {
self.skipped.fetch_add(1, Ordering::Relaxed);
self.done.fetch_add(1, Ordering::Relaxed);
self.publish();
}
/// Sends the terminal event: the capture is over, `error` says why it
/// failed (or `None` on success). A vanished receiver is ignored — the
/// capture's outcome is on disk and in the log either way.
pub async fn finish(&self, error: Option<String>) {
if let Some(tx) = &self.tx {
let _ = tx
.send_async(self.snapshot(true, error.unwrap_or_default()))
.await;
}
}
}
/// One track discovered by the enumeration phase: what to fetch, where to
/// put it, and its listing position (the order prefix).
pub(crate) struct TrackEntry {
pub(crate) track: Track,
pub(crate) dir: PathBuf,
pub(crate) index: usize,
}
/// Downloads one track's audio via the provider's stream URL.
///
/// One shared HTTP client with a connect timeout; each track is bounded by
/// [`DOWNLOAD_TRACK_TIMEOUT`] end to end and never retried (a capture is
/// re-runnable; resuming re-attempts what is missing). Bodies are fetched
/// in bounded [`DOWNLOAD_WINDOW`] ranges and streamed to disk against the
/// capture's remaining byte budget.
#[derive(Debug)]
pub struct Downloader {
http: reqwest::Client,
window: u64,
}
impl Downloader {
/// Builds the shared HTTP client. Fails only when the TLS backend
/// cannot initialize.
pub fn new() -> Result<Self, reqwest::Error> {
Self::with_window(DOWNLOAD_WINDOW)
}
/// [`Self::new`] with an explicit window size — the seam the
/// window-chaining tests use.
pub(crate) fn with_window(window: u64) -> Result<Self, reqwest::Error> {
let http = reqwest::Client::builder()
.connect_timeout(DOWNLOAD_CONNECT_TIMEOUT)
.build()?;
Ok(Self { http, window })
}
/// Streams the stream URL `url` for `track_path` into the file `dest`,
/// returning the audio extension chosen from the response
/// `Content-Type`/URL. Bounded end to end by [`DOWNLOAD_TRACK_TIMEOUT`].
///
/// Error messages carry `track_path`, never the URL (it may embed a
/// token) — reqwest errors are stripped with
/// [`reqwest::Error::without_url`]. Writes no toml: the content store
/// hashes `dest` and decides where it lands
/// (architecture/crabidy-store.md D4).
pub async fn download_to(
&self,
track_path: &str,
url: &str,
dest: &Path,
bytes_left: &mut u64,
) -> Result<String, CaptureError> {
match tokio::time::timeout(
DOWNLOAD_TRACK_TIMEOUT,
self.download_windowed(track_path, url, dest, bytes_left),
)
.await
{
Ok(result) => result,
Err(_) => Err(CaptureError::Download(format!(
"{track_path}: timed out after {}s",
DOWNLOAD_TRACK_TIMEOUT.as_secs()
))),
}
}
/// The unbounded body of [`Self::download_to`]: the windowed range
/// download, streaming the response to `dest` against the byte budget.
async fn download_windowed(
&self,
track_path: &str,
url: &str,
dest: &Path,
bytes_left: &mut u64,
) -> Result<String, CaptureError> {
let download_err = |err: reqwest::Error| {
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
};
// First bounded window; its status decides the mode. Some CDNs
// (googlevideo) 403 plain and open-ended requests, so every
// request carries a bounded range; servers that ignore the header
// answer 200 with the whole body.
let mut start = 0u64;
let mut response = self
.http
.get(url)
.header(
reqwest::header::RANGE,
format!("bytes=0-{}", self.window - 1),
)
.send()
.await
.map_err(download_err)?;
let windowed = match response.status() {
reqwest::StatusCode::PARTIAL_CONTENT => true,
reqwest::StatusCode::OK => false,
status => {
return Err(CaptureError::Download(format!(
"{track_path}: HTTP status {status}"
)))
}
};
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let ext = extension_for(content_type.as_deref(), url);
let mut audio = tokio::fs::File::create(dest).await?;
loop {
// The window's extent and the resource total, from
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
// fallback for the extent).
let (window_end, total) = if windowed {
let content_range = response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.and_then(parse_content_range);
match content_range {
Some((_, range_end, total)) => (range_end + 1, total),
None => (start + response.content_length().unwrap_or(0), None),
}
} else {
(u64::MAX, None)
};
let mut received = 0u64;
while let Some(chunk) = response.chunk().await.map_err(download_err)? {
let len = chunk.len() as u64;
if len > *bytes_left {
return Err(CaptureError::TooLarge("download budget exhausted"));
}
*bytes_left -= len;
received += len;
tokio::io::AsyncWriteExt::write_all(&mut audio, &chunk).await?;
}
if !windowed {
break;
}
start = window_end.max(start + received);
match total {
Some(total) if start >= total => break,
// A short or empty window with no known total: the
// resource ended early.
_ if received == 0 => break,
None if received < self.window => break,
_ => {}
}
response = self
.http
.get(url)
.header(
reqwest::header::RANGE,
format!("bytes={start}-{}", start + self.window - 1),
)
.send()
.await
.map_err(download_err)?;
match response.status() {
reqwest::StatusCode::PARTIAL_CONTENT => {}
// Past the end: everything is on disk.
reqwest::StatusCode::RANGE_NOT_SATISFIABLE => break,
status => {
return Err(CaptureError::Download(format!(
"{track_path}: HTTP status {status}"
)))
}
}
}
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
Ok(ext)
}
}
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
let rest = value.trim().strip_prefix("bytes ")?;
let (range, total) = rest.split_once('/')?;
let (start, end) = range.split_once('-')?;
let total = match total.trim() {
"*" => None,
n => Some(n.parse().ok()?),
};
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
}
/// Mirrors the directory structure under `root` and collects every track
/// with its target directory and listing index. Iterative pre-order over
/// [`ProviderClient::get_lib_node`] (a deep tree must not overflow the
/// stack). A `source_path` that is a track enumerates as a single entry.
/// Enforces `max_dirs`/`max_tracks`.
pub(crate) async fn enumerate<C>(
client: &C,
source_path: &str,
root: &Path,
caps: Caps,
) -> Result<Vec<TrackEntry>, CaptureError>
where
C: ProviderClient + Sync,
{
if client.is_track_path(source_path) {
let track = client
.get_metadata_for_track(source_path)
.await
.map_err(|err| CaptureError::BadSource(format!("{source_path}: {err}")))?;
return Ok(vec![TrackEntry {
track,
dir: root.to_path_buf(),
index: 0,
}]);
}
let mut entries = Vec::new();
let mut dirs = 1usize;
let mut tracks = 0usize;
let mut worklist: Vec<(String, PathBuf)> = vec![(source_path.to_string(), root.to_path_buf())];
while let Some((lib_path, dir)) = worklist.pop() {
let node = client
.get_lib_node(&lib_path)
.await
.map_err(|err| CaptureError::BadSource(format!("{lib_path}: {err}")))?;
for (index, track) in node.tracks.iter().enumerate() {
tracks += 1;
if tracks > caps.max_tracks {
return Err(CaptureError::TooLarge("too many tracks"));
}
entries.push(TrackEntry {
track: track.clone(),
dir: dir.clone(),
index,
});
}
for (index, child) in node.children.iter().enumerate() {
dirs += 1;
if dirs > caps.max_dirs {
return Err(CaptureError::TooLarge("too many directories"));
}
let child_dir = dir.join(fsdy::dir_name(index, &child.title));
tokio::fs::create_dir_all(&child_dir).await?;
worklist.push((child.path.clone(), child_dir));
}
}
Ok(entries)
}
/// Picks the audio file extension: the response `Content-Type` first
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
/// extension, then `bin` — the player probes by content, the extension is
/// a hint.
fn extension_for(content_type: Option<&str>, url: &str) -> String {
let mapped = content_type
.and_then(|ct| ct.split(';').next())
.map(|essence| essence.trim().to_ascii_lowercase())
.and_then(|essence| match essence.as_str() {
"audio/flac" | "audio/x-flac" => Some("flac"),
"audio/mp4" | "audio/m4a" | "audio/x-m4a" => Some("m4a"),
"audio/mpeg" | "audio/mp3" => Some("mp3"),
"audio/ogg" => Some("ogg"),
"audio/wav" | "audio/x-wav" => Some("wav"),
// YouTube bestaudio is usually opus in webm; googlevideo
// URLs carry no path extension to fall back on.
"audio/webm" | "video/webm" => Some("webm"),
_ => None,
});
match mapped.or_else(|| url_extension(url)) {
Some(ext) => ext.to_string(),
None => "bin".to_string(),
}
}
/// The extension of a URL's last path segment (query and fragment
/// stripped), when it looks like one: short and alphanumeric.
fn url_extension(url: &str) -> Option<&str> {
let path = url.split(['?', '#']).next()?;
let segment = path.rsplit('/').next()?;
let (stem, ext) = segment.rsplit_once('.')?;
let plausible = !stem.is_empty()
&& !ext.is_empty()
&& ext.len() <= 5
&& ext.chars().all(|c| c.is_ascii_alphanumeric());
plausible.then_some(ext)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extension_prefers_content_type_then_url_then_bin() {
for (ct, ext) in [
("audio/flac", "flac"),
("audio/mp4", "m4a"),
("audio/m4a", "m4a"),
("audio/mpeg", "mp3"),
("audio/ogg", "ogg"),
("audio/wav", "wav"),
("audio/webm", "webm"),
] {
assert_eq!(extension_for(Some(ct), "https://x.test/s"), ext);
}
// Content-Type parameters must not confuse the mapping.
assert_eq!(
extension_for(Some("audio/flac; charset=binary"), "https://x.test/s"),
"flac"
);
// Unknown or missing types fall back to the URL path's extension…
assert_eq!(
extension_for(None, "https://x.test/media/track.m4a?token=abc"),
"m4a"
);
assert_eq!(
extension_for(Some("application/octet-stream"), "https://x.test/a.flac"),
"flac"
);
// …and to `bin` when the URL has none either.
assert_eq!(extension_for(None, "https://x.test/stream"), "bin");
assert_eq!(extension_for(None, "not a url"), "bin");
}
#[test]
fn content_range_parses_totals_and_wildcards() {
assert_eq!(
parse_content_range("bytes 0-1023/7831134"),
Some((0, 1023, Some(7831134)))
);
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, 9, None)));
assert_eq!(parse_content_range("garbage"), None);
}
#[test]
fn progress_counts_skipped_toward_done_and_finishes_once() {
let (tx, rx) = flume::bounded(16);
let progress = Progress::new("faves", true, tx);
progress.set_total(3);
progress.track_done();
progress.track_skipped();
let events: Vec<CaptureProgress> = rx.drain().collect();
let last = events.last().expect("events published");
assert_eq!(last.tracks_total, 3);
// Skipped counts toward done: the ratio reaches total on success.
assert_eq!(last.tracks_done, 2);
assert_eq!(last.tracks_skipped, 1);
assert!(events.iter().all(|e| !e.finished));
}
#[tokio::test]
async fn progress_terminal_event_carries_the_error() {
let (tx, rx) = flume::bounded(16);
let progress = Progress::new("faves", true, tx);
progress.finish(Some("boom".to_string())).await;
let event = rx.recv_async().await.expect("terminal event");
assert!(event.finished);
assert_eq!(event.error, "boom");
// A silent reporter must not panic anywhere.
let silent = Progress::silent("x", false);
silent.set_total(1);
silent.track_done();
silent.finish(None).await;
}
}

View File

@ -1,568 +0,0 @@
//! Execution of the server-owned CLI subcommands (`guard`, `scan`) and the
//! shared helpers the bundled `cbd` binary reuses (architecture/cli.md D4/D5).
//!
//! The clap *definitions* live in `cbd-cli`; their *execution* lives here
//! because it needs the server's config writer ([`ServerSettings::store`]) and
//! the content store ([`CrabidyStore`]), which `cbd-cli` must not depend on.
use std::error::Error;
use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
use cbd_cli::{GuardArgs, Role, ScanArgs};
#[cfg(feature = "fs")]
use tracing::warn;
#[cfg(feature = "fs")]
use crate::crabidy_store::CrabidyStore;
use crate::settings::ServerSettings;
/// Default server address for the remote `library`/`queue`/`global` commands.
pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051";
/// File-name extensions treated as playable audio by `scan` (lowercased).
/// `opus` is here only when this build can decode it, so `scan` never indexes
/// a file the player would reject (architecture/build-features.md D6).
#[cfg(feature = "fs")]
const AUDIO_EXTENSIONS: &[&str] = &[
"flac",
"mp3",
"m4a",
"aac",
"ogg",
#[cfg(feature = "opus")]
"opus",
"wav",
"webm",
"wma",
"aiff",
"aif",
];
/// The crabidy config directory (`dirs::config_dir()/crabidy`).
fn config_dir() -> Result<PathBuf, Box<dyn Error>> {
dirs::config_dir()
.map(|dir| dir.join("crabidy"))
.ok_or_else(|| "no config directory available".into())
}
/// Builds a resolved [`cbd_cli::Connection`] from the shared remote flags,
/// falling back to the client defaults (localhost, no credentials).
pub fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
cbd_cli::Connection {
address: remote
.address
.clone()
.unwrap_or_else(|| DEFAULT_ADDRESS.to_string()),
user: remote.user.clone().unwrap_or_default(),
password: remote.password.clone().unwrap_or_default(),
}
}
/// Resolves the password from the CLI argument, or reads it from stdin when
/// omitted. Never echoed or logged. Rejects an empty password.
fn resolve_password(arg: Option<String>) -> Result<String, Box<dyn Error>> {
let password = match arg {
Some(password) => password,
None => {
let mut stdin = std::io::stdin();
if stdin.is_terminal() {
eprintln!("Enter password (input is not hidden):");
}
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
buf.trim_end_matches(['\r', '\n']).to_string()
}
};
if password.is_empty() {
return Err("empty password".into());
}
Ok(password)
}
/// Writes `hash` into the role's `[auth]` field of `crabidy-server.toml` in
/// `config_dir`, preserving the other roles (the testable core of `guard`'s
/// config write).
pub fn write_role_hash(config_dir: &Path, role: Role, hash: String) -> Result<(), String> {
let mut settings = ServerSettings::load(config_dir)?;
match role {
Role::Owner => settings.auth.owner = Some(hash),
Role::QueueOwner => settings.auth.queue_owner = Some(hash),
Role::Appender => settings.auth.queue_appender = Some(hash),
}
// Refuse to persist a config the server would reject on load: roles
// must be guarded from the top down (architecture/roles-auth.md). This
// catches `guard queue-owner` before `guard owner`.
settings.auth.validate()?;
settings.store(config_dir)
}
/// `guard <role> [password] [--no-config]`: hash a role password (argon2id),
/// print the PHC string to stdout, and — unless `--no-config` — write it into
/// `crabidy-server.toml`'s `[auth]` (architecture/cli.md D4). Only the hash is
/// printed to stdout, so the command stays pipeable; the confirmation goes to
/// stderr and the password is never printed or logged.
pub async fn guard(args: GuardArgs) -> Result<(), Box<dyn Error>> {
let password = resolve_password(args.password)?;
let hash = crate::auth::hash_password(&password)?;
println!("{hash}");
if !args.no_config {
let dir = config_dir()?;
write_role_hash(&dir, args.role, hash)?;
eprintln!(
"wrote the {} hash to {}",
args.role.user_name(),
dir.join(crate::settings::SETTINGS_FILE).display()
);
}
Ok(())
}
/// `audio-devices [device]`: with no argument, list the available audio output
/// devices so the user can choose one for `[audio] device` in
/// `crabidy-server.toml`, marking the device the current config selects (same
/// case-insensitive substring match the server applies at startup). With a
/// `device` argument, write it into `[audio] device` and then list — so one
/// command both configures and confirms. Fixes the common Raspberry Pi case
/// where the default device is HDMI and audio plays but is silent on the
/// jack/DAC.
pub fn audio_devices(select: Option<String>) -> Result<(), Box<dyn Error>> {
// Setting a device needs a writable config dir; listing tolerates its
// absence.
if let Some(device) = select {
let dir = config_dir()?;
let mut settings = ServerSettings::load(&dir)?;
settings.audio.device = Some(device.clone());
settings.store(&dir)?;
println!(
"Set [audio] device = \"{device}\" in {}",
dir.join(crate::settings::SETTINGS_FILE).display()
);
// The server matches the same way; warn on a fragment that currently
// resolves to nothing so a typo is caught here, not as silent output.
let needle = device.to_lowercase();
let names = audio_player::output_device_names();
if !names.is_empty()
&& !names
.iter()
.any(|name| name.to_lowercase().contains(&needle))
{
eprintln!(
"warning: no current output device name contains \"{device}\"; the server \
will fall back to the system default until one matches"
);
}
println!("Restart the server for it to take effect.\n");
}
let dir = config_dir().ok();
let configured = dir
.as_deref()
.and_then(|dir| ServerSettings::load(dir).ok())
.and_then(|settings| settings.audio.device);
let names = audio_player::output_device_names();
if names.is_empty() {
println!("No audio output devices found.");
return Ok(());
}
let needle = configured.as_ref().map(|device| device.to_lowercase());
println!("Audio output devices (* = selected by the current config):");
for name in &names {
let selected = needle
.as_ref()
.is_some_and(|needle| name.to_lowercase().contains(needle));
println!(" {}{name}", if selected { "* " } else { " " });
}
match configured {
Some(device) => println!("\n[audio] device = \"{device}\""),
None => {
let path = dir
.map(|dir| {
dir.join(crate::settings::SETTINGS_FILE)
.display()
.to_string()
})
.unwrap_or_else(|| crate::settings::SETTINGS_FILE.to_string());
println!(
"\nNo [audio] device set (using the system default). To pin one, add to {path}:\n\n[audio]\ndevice = \"Headphones\" # a name or fragment from the list above"
);
}
}
Ok(())
}
/// `scan` without the `fs` feature: the command exists (the clap surface is
/// feature-independent, so completions and the man page never vary) but this
/// build has no sidecar writer or content store
/// (architecture/build-features.md D5/D9).
#[cfg(not(feature = "fs"))]
pub async fn scan(_args: ScanArgs) -> Result<(), Box<dyn Error>> {
Err("this binary was built without the `fs` feature, so it cannot index a music folder".into())
}
/// The build features this binary was compiled with, in a stable order:
/// the providers it can mount ([`crate::settings::BUILT_IN_PROVIDERS`])
/// followed by the non-provider features (`opus`, `spectrum`, `web-ui`).
///
/// Used by the `features` subcommand and by the startup log line, so "why is
/// `/tidal` missing?" is answerable from the binary itself
/// (architecture/build-features.md D4).
pub fn build_features() -> Vec<&'static str> {
let mut features: Vec<&'static str> = crate::settings::BUILT_IN_PROVIDERS.to_vec();
if cfg!(feature = "opus") {
features.push("opus");
}
if cfg!(feature = "spectrum") {
features.push("spectrum");
}
if cfg!(feature = "web-ui") {
features.push("web-ui");
}
features
}
/// `features`: print what this build can do — one feature per line, so it is
/// greppable — and where the runtime `providers` list that prunes it further
/// lives (architecture/build-features.md D4).
pub fn features() -> Result<(), Box<dyn Error>> {
for feature in build_features() {
println!("{feature}");
}
let path = config_dir()
.map(|dir| {
dir.join(crate::settings::SETTINGS_FILE)
.display()
.to_string()
})
.unwrap_or_else(|_| crate::settings::SETTINGS_FILE.to_string());
eprintln!(
"\nProviders can be pruned further at runtime with the `providers` list in {path}; \
names missing above are not in this binary and cannot be enabled there."
);
Ok(())
}
/// The outcome of a `scan` walk, for a concise summary and for tests.
#[cfg(feature = "fs")]
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ScanSummary {
/// Sidecar `.cbd-track.toml` files written this run.
pub written: usize,
/// Audio files skipped because a sidecar already existed.
pub skipped: usize,
}
/// `scan <path> [--capture|--move]`: index a music folder (architecture/cli.md
/// D5). Opens the content store only when a capture/move is requested.
#[cfg(feature = "fs")]
pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> {
if !args.path.is_dir() {
return Err(format!("not a directory: {}", args.path.display()).into());
}
let store = if args.capture || args.move_ {
let tree_root = CrabidyStore::default_tree_root()
.ok_or("--capture/--move need a state directory for the content store")?;
let store_root = CrabidyStore::default_store_root()
.ok_or("--capture/--move need a data directory for the content store")?;
Some(CrabidyStore::open(tree_root, store_root).await?)
} else {
None
};
let summary = scan_dir(&args.path, store.as_ref(), args.move_).await?;
println!(
"scan complete: {} written, {} skipped",
summary.written, summary.skipped
);
Ok(())
}
/// Walks `root` (bounded, hidden entries and symlinks skipped), writing a
/// `<stem>.cbd-track.toml` beside each audio file. With `store` set, each file
/// is ingested (copied, or moved when `move_it`) into the content store and
/// the sidecar points at the store entry; otherwise the sidecar's playable is
/// a relative [`fsdy::Playable::File`]. An existing sidecar is never
/// clobbered. Unreadable entries are warnings, not failures.
#[cfg(feature = "fs")]
pub async fn scan_dir(
root: &Path,
store: Option<&CrabidyStore>,
move_it: bool,
) -> Result<ScanSummary, Box<dyn Error>> {
let mut summary = ScanSummary::default();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let mut read_dir = match tokio::fs::read_dir(&dir).await {
Ok(read_dir) => read_dir,
Err(err) => {
warn!(dir = %dir.display(), "cannot read directory: {err}");
continue;
}
};
loop {
let entry = match read_dir.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(err) => {
warn!(dir = %dir.display(), "error listing directory: {err}");
break;
}
};
let Ok(file_type) = entry.file_type().await else {
continue;
};
if file_type.is_symlink() {
continue;
}
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.starts_with('.') {
continue;
}
let path = entry.path();
if file_type.is_dir() {
stack.push(path);
} else if is_audio_file(&path) {
match scan_file(&dir, &path, store, move_it).await {
Ok(true) => summary.written += 1,
Ok(false) => summary.skipped += 1,
Err(err) => warn!(file = %path.display(), "cannot index file: {err}"),
}
}
}
}
Ok(summary)
}
/// Indexes one audio file. Returns `true` when a sidecar was written, `false`
/// when one already existed (left untouched).
#[cfg(feature = "fs")]
async fn scan_file(
dir: &Path,
file: &Path,
store: Option<&CrabidyStore>,
move_it: bool,
) -> Result<bool, Box<dyn Error>> {
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.ok_or("audio file has no usable name")?
.to_string();
let file_name = file
.file_name()
.and_then(|n| n.to_str())
.ok_or("audio file has no usable name")?
.to_string();
let sidecar = dir.join(format!("{stem}{}", fsdy::TRACK_FILE_SUFFIX));
if tokio::fs::try_exists(&sidecar).await.unwrap_or(false) {
warn!(sidecar = %sidecar.display(), "leaving existing track toml untouched");
return Ok(false);
}
let playable = match store {
Some(store) => {
let name = store.ingest_file(file, move_it).await?;
fsdy::PlayableSpec {
file: None,
url: None,
link: None,
store: Some(name),
skipped: None,
}
}
None => fsdy::PlayableSpec {
file: Some(PathBuf::from(&file_name)),
url: None,
link: None,
store: None,
skipped: None,
},
};
let track_file = fsdy::TrackFile {
title: stem,
artist: String::new(),
duration: None,
album: None,
// A scanned local file has no provider-internal id.
provider_item_id: None,
playable,
};
tokio::fs::write(&sidecar, track_file.to_toml()?).await?;
Ok(true)
}
/// Whether `path`'s extension is a known audio extension (case-insensitive).
#[cfg(feature = "fs")]
fn is_audio_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.map(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str()))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn guard_writes_the_role_hash_and_preserves_others() {
let dir = TempDir::new().expect("tempdir");
// Guarding must run top-down; owner first, then queue-owner.
write_role_hash(dir.path(), Role::Owner, "$argon2id$owner".to_string()).expect("owner");
write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string()).expect("qo");
let settings = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner"));
assert_eq!(settings.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
assert!(settings.auth.queue_appender.is_none());
}
#[test]
fn guard_refuses_to_write_a_role_below_an_unguarded_one() {
let dir = TempDir::new().expect("tempdir");
// queue-owner without owner would let anonymous callers outrank it.
let err = write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string())
.expect_err("must refuse");
assert!(err.contains("owner"), "{err}");
// Nothing was written, so the config still loads clean.
assert!(ServerSettings::load(dir.path()).is_ok());
}
#[test]
fn build_features_lists_providers_then_extras() {
let features = build_features();
// Providers first, in BUILT_IN_PROVIDERS order, then the extras.
let providers = crate::settings::BUILT_IN_PROVIDERS;
assert_eq!(&features[..providers.len()], providers);
let extras = &features[providers.len()..];
for extra in extras {
assert!(
["opus", "spectrum", "web-ui"].contains(extra),
"unexpected extra {extra}"
);
}
assert_eq!(extras.contains(&"opus"), cfg!(feature = "opus"));
assert_eq!(extras.contains(&"spectrum"), cfg!(feature = "spectrum"));
assert_eq!(extras.contains(&"web-ui"), cfg!(feature = "web-ui"));
}
/// `scan` must index `.opus` exactly when this build can decode it
/// (architecture/build-features.md D6) — and never change its verdict on
/// the other extensions.
#[cfg(feature = "fs")]
#[test]
fn opus_is_scannable_only_with_the_decoder() {
assert_eq!(
is_audio_file(Path::new("/music/a.opus")),
cfg!(feature = "opus")
);
assert_eq!(
is_audio_file(Path::new("/music/A.OPUS")),
cfg!(feature = "opus")
);
for name in ["a.flac", "a.mp3", "a.m4a", "a.ogg", "a.wav"] {
assert!(is_audio_file(&PathBuf::from(name)), "{name}");
}
for name in ["a.jpg", "a.cbd-track.toml", "a"] {
assert!(!is_audio_file(&PathBuf::from(name)), "{name}");
}
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_walks_past_opus_files_it_cannot_play() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("song.opus"), b"AUDIO").expect("write");
std::fs::write(dir.path().join("other.flac"), b"AUDIO").expect("write");
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
let expected = if cfg!(feature = "opus") { 2 } else { 1 };
assert_eq!(summary.written, expected);
assert_eq!(
dir.path().join("song.cbd-track.toml").is_file(),
cfg!(feature = "opus")
);
assert!(dir.path().join("other.cbd-track.toml").is_file());
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_writes_file_playables_and_skips_existing_tomls() {
let dir = TempDir::new().expect("tempdir");
let sub = dir.path().join("album");
std::fs::create_dir(&sub).expect("mkdir");
std::fs::write(sub.join("song.flac"), b"AUDIO").expect("write");
std::fs::write(dir.path().join("cover.jpg"), b"jpg").expect("write");
std::fs::write(dir.path().join(".hidden.mp3"), b"x").expect("write");
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
assert_eq!(summary.written, 1);
assert_eq!(summary.skipped, 0);
let sidecar = sub.join("song.cbd-track.toml");
assert!(sidecar.is_file());
let text = std::fs::read_to_string(&sidecar).expect("read");
let track = fsdy::TrackFile::parse(&text).expect("parse");
assert_eq!(track.title, "song");
assert_eq!(
track.playable().expect("playable"),
fsdy::Playable::File(PathBuf::from("song.flac"))
);
// A re-scan leaves the hand-written toml untouched.
let again = scan_dir(dir.path(), None, false).await.expect("rescan");
assert_eq!(again.written, 0);
assert_eq!(again.skipped, 1);
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() {
let src = TempDir::new().expect("srcdir");
std::fs::write(src.path().join("a.flac"), b"AUDIO").expect("write");
let store_dir = TempDir::new().expect("storedir");
let store = CrabidyStore::open(
store_dir.path().join("state"),
store_dir.path().join("store"),
)
.await
.expect("open store");
let summary = scan_dir(src.path(), Some(&store), false)
.await
.expect("capture scan");
assert_eq!(summary.written, 1);
// Source stays (copy), the store has the audio, the toml points there.
assert!(src.path().join("a.flac").is_file());
let text = std::fs::read_to_string(src.path().join("a.cbd-track.toml")).expect("read");
let track = fsdy::TrackFile::parse(&text).expect("parse");
match track.playable().expect("playable") {
fsdy::Playable::Store(name) => assert!(store.store_dir().join(&name).is_file()),
other => panic!("expected a store playable, got {other:?}"),
}
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_move_removes_the_source_audio() {
let src = TempDir::new().expect("srcdir");
std::fs::write(src.path().join("b.mp3"), b"BYTES").expect("write");
let store_dir = TempDir::new().expect("storedir");
let store = CrabidyStore::open(
store_dir.path().join("state"),
store_dir.path().join("store"),
)
.await
.expect("open store");
scan_dir(src.path(), Some(&store), true)
.await
.expect("move scan");
// The original audio is gone; only the toml remains beside it.
assert!(!src.path().join("b.mp3").exists());
assert!(src.path().join("b.cbd-track.toml").is_file());
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More