Compare commits
121 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
1f41c45b95 | |
|
|
bb084dd62b | |
|
|
001abcc35c | |
|
|
1228c7cb70 | |
|
|
bbebee0b4f | |
|
|
2bfdac25a7 | |
|
|
a2e3da6aa5 | |
|
|
621823d930 | |
|
|
87b98193d8 | |
|
|
d8bd06219c | |
|
|
d25620c6a9 | |
|
|
d5b35a4da2 | |
|
|
430c87451b | |
|
|
d2f687983e | |
|
|
43902fc818 | |
|
|
8e3c210864 | |
|
|
1db88e0f5a | |
|
|
034737d14f | |
|
|
cc95e109fc | |
|
|
1e8afd5f41 | |
|
|
0d08c765a1 | |
|
|
956a490606 | |
|
|
d0b01c75d0 | |
|
|
378066470e | |
|
|
bdc961501d | |
|
|
1b2f01a2b6 | |
|
|
7fe4326923 | |
|
|
95700bf31f | |
|
|
ad19b6352e | |
|
|
e2c1b44cdb | |
|
|
d630c9e550 | |
|
|
ba4775b0bb | |
|
|
ab3bd7c63a | |
|
|
af04573b72 | |
|
|
cd3a16f95c | |
|
|
a03e3de84e | |
|
|
1b838578e2 | |
|
|
6f3b60254e | |
|
|
2e94760a69 | |
|
|
0a9456e173 | |
|
|
238e7c8f87 | |
|
|
7df5f0a1c2 | |
|
|
06381949ab | |
|
|
c4001df74d | |
|
|
739c5a805a | |
|
|
6abda3aa58 | |
|
|
7091d37c32 | |
|
|
9fba9708bf | |
|
|
f4309aa327 | |
|
|
11b2a1bd38 | |
|
|
4e69261e02 | |
|
|
28a4c155d0 | |
|
|
4c0c5f1401 | |
|
|
33fd3b227c | |
|
|
ef1e56e1f4 | |
|
|
bc3d2e099e | |
|
|
b6f1275d6b | |
|
|
42c5e9dbc2 | |
|
|
659e678522 | |
|
|
0a0c35c531 | |
|
|
3b81faeb9d | |
|
|
a275d3bc77 | |
|
|
5bcddb9027 | |
|
|
89d5fffd48 | |
|
|
d078e65a7d | |
|
|
3a03114cb9 | |
|
|
cd33b790b5 | |
|
|
7eaa8fa9b4 | |
|
|
be2676080d | |
|
|
95ea1e44a0 | |
|
|
6ca8607586 | |
|
|
b50cf862b3 | |
|
|
0a0d50f748 | |
|
|
c4abadc8e4 | |
|
|
e215dd8b87 | |
|
|
56098f7c26 | |
|
|
99419dcdcf | |
|
|
dbd1b955fb | |
|
|
21d4fddb2f | |
|
|
ef69afdd5f | |
|
|
80c4b6e7ed | |
|
|
7f34f869a9 | |
|
|
bdfc0ec29c | |
|
|
b3fec6d3c0 | |
|
|
0b9970b550 | |
|
|
572be04206 | |
|
|
21fc4dc15a | |
|
|
550237f69a | |
|
|
435af91d9c | |
|
|
4e59e50943 | |
|
|
1c83217745 | |
|
|
383173046d | |
|
|
c84dec9ca2 | |
|
|
973bb7bbc6 | |
|
|
6f787285bf | |
|
|
214dece19f | |
|
|
58f7f9c66b | |
|
|
8032bec8e1 | |
|
|
c6e9f71cf4 | |
|
|
7f0e900c56 | |
|
|
655e8054b8 | |
|
|
4e44f672e8 | |
|
|
20e071f0af | |
|
|
8bb653ab3e | |
|
|
955c7ea5b4 | |
|
|
4dd5f01217 | |
|
|
ccab43a133 | |
|
|
333c6e040a | |
|
|
d741523e53 | |
|
|
8586194096 | |
|
|
5d2f88aa98 | |
|
|
f783e8152b | |
|
|
9706e3b5d2 | |
|
|
86ce63ae61 | |
|
|
634b89b9e8 | |
|
|
21e1b10c0b | |
|
|
d504ebc85f | |
|
|
56bc0b0d04 | |
|
|
6d8bc7f166 | |
|
|
6eb5a87b15 | |
|
|
91716daf84 |
|
|
@ -1 +1,34 @@
|
|||
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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
129
Cargo.toml
129
Cargo.toml
|
|
@ -1,9 +1,136 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"absdy",
|
||||
"audio-player",
|
||||
"cbd",
|
||||
"cbd-cli",
|
||||
"cbd-tui",
|
||||
"cbd-web",
|
||||
"crabidy-core",
|
||||
"crabidy-server",
|
||||
"stream-download",
|
||||
"fsdy",
|
||||
"fyyd",
|
||||
"jamendody",
|
||||
"rssdy",
|
||||
"soundclouddy",
|
||||
"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" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,478 @@
|
|||
# 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/).
|
||||
|
|
@ -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 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 cmake 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
[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"] }
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
//! 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,
|
||||
}
|
||||
|
|
@ -0,0 +1,651 @@
|
|||
//! 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");
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
// 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());
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
//! 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());
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
# 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).
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
# 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 (decorate–sort–undecorate, 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
|
||||
lock–mutate–broadcast 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.
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# 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.
|
||||
|
|
@ -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 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 cmake 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
|
||||
|
|
|
|||
|
|
@ -1,19 +1,39 @@
|
|||
[package]
|
||||
name = "audio-player"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
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"]
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
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
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
use std::{thread, time::Duration};
|
||||
|
||||
use audio_player::{Player, PlayerMessage};
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -13,7 +11,10 @@ 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) => {
|
||||
|
|
@ -30,7 +31,10 @@ 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) => {
|
||||
|
|
|
|||
|
|
@ -1,318 +0,0 @@
|
|||
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(¤t_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 {}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
//! 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(¶ms.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
mod decoder;
|
||||
mod hls;
|
||||
// Ogg-Opus needs symphonia's Ogg demuxer plus the bundled libopus decoder, so
|
||||
// it is the crate's one feature (architecture/build-features.md D6).
|
||||
#[cfg(feature = "opus")]
|
||||
mod opus_source;
|
||||
mod player;
|
||||
mod player_engine;
|
||||
mod spectrum_tap;
|
||||
pub mod windowed_http;
|
||||
|
||||
pub use decoder::MediaInfo;
|
||||
pub use player::{Player, PlayerError};
|
||||
pub use player_engine::PlayerMessage;
|
||||
pub use player_engine::{output_device_names, MediaInfo, PlayerMessage};
|
||||
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! 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""));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,180 +3,200 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::Result;
|
||||
use flume::{Receiver, Sender};
|
||||
use tracing::{error, warn};
|
||||
use tracing::error;
|
||||
|
||||
use crate::decoder::MediaInfo;
|
||||
use crate::player_engine::{PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
||||
use std::sync::Arc;
|
||||
|
||||
// TODO:
|
||||
// * Emit buffering
|
||||
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
||||
use crate::spectrum_tap::SpectrumTap;
|
||||
|
||||
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 {
|
||||
let (tx_engine, rx_engine) = flume::bounded(10);
|
||||
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_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
|
||||
flume::bounded(10);
|
||||
flume::bounded(16);
|
||||
|
||||
let tx_decoder = tx_engine.clone();
|
||||
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();
|
||||
|
||||
// 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 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(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();
|
||||
}
|
||||
let engine =
|
||||
match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) {
|
||||
Err(e) => {
|
||||
warn!("Recv error {}", e);
|
||||
error!("Could not initialize player: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(engine) => engine,
|
||||
};
|
||||
engine.run(rx_engine);
|
||||
});
|
||||
|
||||
Self {
|
||||
messages,
|
||||
tx_engine,
|
||||
spectrum,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// The spectrum tap the played audio is mirrored into.
|
||||
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
|
||||
self.spectrum.clone()
|
||||
}
|
||||
|
||||
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
.send(PlayerEngineCommand::Play(source_str.to_string(), tx))?;
|
||||
.send_async(PlayerEngineCommand::Play(source_str.to_string(), tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn restart(&self) -> Result<MediaInfo> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::Restart(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::Restart(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn elpased(&self) -> Result<Duration> {
|
||||
pub async fn elapsed(&self) -> Result<Duration> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::GetElapsed(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::GetElapsed(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn duration(&self) -> Result<Duration> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::GetDuration(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::GetDuration(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::SeekTo(time, tx))?;
|
||||
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?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn volume(&self) -> Result<f32> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::GetVolume(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::GetVolume(tx))
|
||||
.await?;
|
||||
Ok(rx.recv_async().await?)
|
||||
}
|
||||
|
||||
pub async fn is_paused(&self) -> Result<bool> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::GetPaused(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::GetPaused(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn set_volume(&self, volume: f32) -> Result<f32> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
.send(PlayerEngineCommand::SetVolume(volume, tx))?;
|
||||
.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?;
|
||||
Ok(rx.recv_async().await?)
|
||||
}
|
||||
|
||||
pub async fn pause(&self) -> Result<()> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::Pause(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::Pause(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn unpause(&self) -> Result<()> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::Unpause(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::Unpause(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn toggle_play(&self) -> Result<bool> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::TogglePlay(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::TogglePlay(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine.send(PlayerEngineCommand::Stop(tx))?;
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::Stop(tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,80 @@
|
|||
use flume::Sender;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::{fs::File, sync::atomic::Ordering};
|
||||
use symphonia::core::probe::Hint;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
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 url::Url;
|
||||
|
||||
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;
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PlayerEngineCommand {
|
||||
Play(String, Sender<Result<MediaInfo>>),
|
||||
|
|
@ -26,10 +87,20 @@ 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>>),
|
||||
Eos,
|
||||
SetElapsed(Duration),
|
||||
/// 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),
|
||||
}
|
||||
|
||||
pub enum PlayerMessage {
|
||||
|
|
@ -46,8 +117,10 @@ pub enum PlayerMessage {
|
|||
EndOfStream,
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// * Emit buffering
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MediaInfo {
|
||||
pub duration: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlayerEngineError {
|
||||
|
|
@ -55,85 +128,391 @@ 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>,
|
||||
sink: Sink,
|
||||
// We need to keep the stream around as it will stop playing when it's dropped
|
||||
_stream: OutputStream,
|
||||
_handle: OutputStreamHandle,
|
||||
/// 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,
|
||||
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, handle) = OutputStream::try_default()?;
|
||||
let sink = Sink::try_new(&handle)?;
|
||||
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")?;
|
||||
Ok(Self {
|
||||
current_source: None,
|
||||
media_info: None,
|
||||
elapsed: Duration::default(),
|
||||
generation: 0,
|
||||
sink,
|
||||
_stream,
|
||||
_handle: handle,
|
||||
_stream: stream,
|
||||
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> {
|
||||
let tx_player = self.tx_player.clone();
|
||||
let tx_engine = self.tx_engine.clone();
|
||||
// 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)?;
|
||||
|
||||
self.reset();
|
||||
self.append_source(source);
|
||||
|
||||
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);
|
||||
let media_info = MediaInfo { duration };
|
||||
self.media_info = Some(media_info.clone());
|
||||
self.current_source = Some(source_str.to_string());
|
||||
|
||||
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.notify(PlayerMessage::Duration {
|
||||
duration: duration.unwrap_or_default(),
|
||||
});
|
||||
|
||||
self.sink.append(decoder);
|
||||
self.sink.play();
|
||||
self.notify(PlayerMessage::Playing);
|
||||
debug!(duration = ?duration, "started playback");
|
||||
|
||||
self.tx_player
|
||||
.send(PlayerMessage::Playing)
|
||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||
Ok(media_info)
|
||||
}
|
||||
|
||||
Ok(media_info_copy)
|
||||
/// 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}");
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
pub fn restart(&mut self) -> Result<MediaInfo> {
|
||||
|
|
@ -148,9 +527,7 @@ impl PlayerEngine {
|
|||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
self.sink.pause();
|
||||
self.tx_player
|
||||
.send(PlayerMessage::Paused)
|
||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||
self.notify(PlayerMessage::Paused);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -159,9 +536,7 @@ impl PlayerEngine {
|
|||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
self.sink.play();
|
||||
self.tx_player
|
||||
.send(PlayerMessage::Playing)
|
||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||
self.notify(PlayerMessage::Playing);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -171,9 +546,11 @@ 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -183,9 +560,7 @@ impl PlayerEngine {
|
|||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
self.reset();
|
||||
self.tx_player
|
||||
.send(PlayerMessage::Stopped)
|
||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||
self.notify(PlayerMessage::Stopped);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -212,77 +587,261 @@ impl PlayerEngine {
|
|||
if self.is_stopped() {
|
||||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
Ok(self.elapsed)
|
||||
Ok(self.sink.get_pos())
|
||||
}
|
||||
|
||||
/// Seeks to an absolute position, clamped by [`seek_target`]. Returns the
|
||||
/// position actually reached.
|
||||
pub fn seek_to(&self, time: Duration) -> Result<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)
|
||||
if self.is_stopped() {
|
||||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
self.seek_sink(seek_target(time, 0, self.known_duration()))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.sink.volume()
|
||||
if self.muted {
|
||||
self.pre_mute_volume
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn handle_eos(&mut self) {
|
||||
self.reset();
|
||||
self.tx_player
|
||||
.send(PlayerMessage::EndOfStream)
|
||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||
/// 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_elapsed(&mut self, elapsed: Duration) {
|
||||
self.elapsed = elapsed;
|
||||
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);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.elapsed = Duration::default();
|
||||
self.current_source = None;
|
||||
self.sink.pause();
|
||||
self.media_info = None;
|
||||
self.generation += 1;
|
||||
self.sink.stop();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
fn notify(&self, message: PlayerMessage) {
|
||||
self.tx_player
|
||||
.send(message)
|
||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,547 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
[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"]
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
//! 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
|
||||
}
|
||||
|
|
@ -0,0 +1,563 @@
|
|||
//! 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))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,45 @@
|
|||
[package]
|
||||
name = "cbd-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
# 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"]
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
//! 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
|
|
@ -0,0 +1,246 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ use std::collections::HashMap;
|
|||
|
||||
use flume::Sender;
|
||||
use ratatui::{
|
||||
backend::Backend,
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::Span,
|
||||
|
|
@ -13,16 +12,28 @@ use ratatui::{
|
|||
use crabidy_core::proto::crabidy::LibraryNode;
|
||||
|
||||
use super::{
|
||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
||||
Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
|
||||
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||
};
|
||||
|
||||
pub struct Library {
|
||||
title: String,
|
||||
uuid: 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,
|
||||
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>,
|
||||
}
|
||||
|
||||
|
|
@ -30,40 +41,92 @@ impl Library {
|
|||
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
||||
Self {
|
||||
title: "Library".to_string(),
|
||||
uuid: "node:/".to_string(),
|
||||
path: crabidy_core::ROOT_PATH.to_string(),
|
||||
is_creatable: false,
|
||||
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>> {
|
||||
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
|
||||
self.selection().map(|(paths, _labels)| paths)
|
||||
}
|
||||
pub fn ascend(&mut self) {
|
||||
if let Some(parent) = self.parent.as_ref() {
|
||||
self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
|
||||
let _ = self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
|
||||
}
|
||||
}
|
||||
pub fn dive(&mut self) {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
let item = &self.list[idx];
|
||||
if let Some(item) = self.resolved() {
|
||||
if let UiItemKind::Node = item.kind {
|
||||
self.tx
|
||||
.send(MessageFromUi::GetLibraryNode(item.uuid.clone()));
|
||||
let _ = self
|
||||
.tx
|
||||
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,93 +154,133 @@ 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.uuid).unwrap_or(&0)
|
||||
*self.positions.get(&self.path).unwrap_or(&0)
|
||||
}
|
||||
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);
|
||||
}
|
||||
/// 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 update(&mut self, node: LibraryNode) {
|
||||
if node.tracks.is_empty() && node.children.is_empty() {
|
||||
// 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() {
|
||||
return;
|
||||
}
|
||||
|
||||
// if children empty and tracks empty return
|
||||
self.uuid = node.uuid;
|
||||
self.path = node.path;
|
||||
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<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
|
||||
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.
|
||||
let library_items: Vec<ListItem> = self
|
||||
.list
|
||||
.filter
|
||||
.visible()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let text = if i.marked {
|
||||
.map(|&real| &self.list[real])
|
||||
.enumerate()
|
||||
.map(|(idx, i)| {
|
||||
let mut text = if i.marked {
|
||||
format!("* {}", i.title)
|
||||
} else {
|
||||
i.title.to_string()
|
||||
};
|
||||
let style = if i.marked {
|
||||
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 {
|
||||
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()
|
||||
};
|
||||
return ListItem::new(Span::from(text)).style(style);
|
||||
// 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)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -191,7 +294,17 @@ impl Library {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title(self.title.clone()),
|
||||
.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()
|
||||
}),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
|
|
@ -209,15 +322,21 @@ impl Library {
|
|||
|
||||
impl StatefulList for Library {
|
||||
fn get_size(&self) -> usize {
|
||||
self.list.len()
|
||||
// Navigation operates on the filtered (visible) view.
|
||||
self.filter.view_len()
|
||||
}
|
||||
|
||||
fn select(&mut self, idx: Option<usize>) {
|
||||
if let Some(pos) = idx {
|
||||
self.positions
|
||||
.entry(self.uuid.clone())
|
||||
.and_modify(|e| *e = pos)
|
||||
.or_insert(pos);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
self.list_state.select(idx);
|
||||
}
|
||||
|
|
@ -226,3 +345,79 @@ 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,74 @@
|
|||
pub use ratatui::widgets::ListState;
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Move marking stuff here, to be able to use it in queue as well
|
||||
pub trait StatefulList {
|
||||
|
|
@ -72,10 +142,6 @@ pub trait StatefulList {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected().is_some()
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.get_size() == 0
|
||||
}
|
||||
|
|
@ -97,3 +163,256 @@ 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
|
|
@ -1,6 +1,5 @@
|
|||
use flume::Sender;
|
||||
use ratatui::{
|
||||
backend::Backend,
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::Span,
|
||||
|
|
@ -11,43 +10,196 @@ use ratatui::{
|
|||
use crabidy_core::proto::crabidy::Queue as QueueData;
|
||||
|
||||
use super::{
|
||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED,
|
||||
carry_marks, Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
|
||||
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||
};
|
||||
|
||||
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) {
|
||||
self.tx.send(MessageFromUi::NextTrack);
|
||||
let _ = self.tx.send(MessageFromUi::NextTrack);
|
||||
}
|
||||
pub fn play_prev(&self) {
|
||||
self.tx.send(MessageFromUi::PrevTrack);
|
||||
let _ = self.tx.send(MessageFromUi::PrevTrack);
|
||||
}
|
||||
pub fn play_selected(&self) {
|
||||
if let Some(pos) = self.selected() {
|
||||
self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
||||
if let Some(pos) = self.selected_position() {
|
||||
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
||||
}
|
||||
}
|
||||
pub fn select_current(&mut self) {
|
||||
self.select(Some(self.current_position));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
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]));
|
||||
/// 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 update_position(&mut self, pos: usize) {
|
||||
|
|
@ -55,43 +207,108 @@ 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(|(i, t)| UiItem {
|
||||
uuid: t.uuid.clone(),
|
||||
.map(|(idx, t)| UiItem {
|
||||
path: t.path.clone(),
|
||||
title: format!("{} - {}", t.artist, t.title),
|
||||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
marked: carried.get(idx).copied().unwrap_or(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();
|
||||
}
|
||||
|
||||
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
|
||||
let queue_items: Vec<ListItem> = self
|
||||
.list
|
||||
/// 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()
|
||||
.iter()
|
||||
.map(|&real| (real, &self.list[real]))
|
||||
.enumerate()
|
||||
.map(|(idx, item)| {
|
||||
let active = idx == self.current_position;
|
||||
.map(|(idx, (real, item))| {
|
||||
let active = real == self.current_position;
|
||||
|
||||
let title = if active {
|
||||
format!("> {}", item.title)
|
||||
} else {
|
||||
item.title.to_string()
|
||||
};
|
||||
let style = if active {
|
||||
// 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 {
|
||||
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(
|
||||
|
|
@ -103,7 +320,19 @@ impl Queue {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title("Queue"),
|
||||
// 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}"),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.highlight_style(Style::default().bg(if focused {
|
||||
COLOR_PRIMARY
|
||||
|
|
@ -117,7 +346,8 @@ impl Queue {
|
|||
|
||||
impl StatefulList for Queue {
|
||||
fn get_size(&self) -> usize {
|
||||
self.list.len()
|
||||
// Navigation operates on the filtered (visible) view.
|
||||
self.filter.view_len()
|
||||
}
|
||||
|
||||
fn select(&mut self, idx: Option<usize>) {
|
||||
|
|
@ -128,3 +358,315 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
//! The client-side register: what `y`, `d`, `c`, and `C` put aside and `p`/`P`
|
||||
//! paste back (architecture/queue-register.md D1–D3).
|
||||
//!
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
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)]
|
||||
|
|
@ -8,10 +16,530 @@ 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,431 @@
|
|||
//! 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();
|
||||
}
|
||||
|
|
@ -1,341 +1,138 @@
|
|||
mod app;
|
||||
mod config;
|
||||
mod rpc;
|
||||
//! 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).
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
io,
|
||||
sync::OnceLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
|
||||
use cbd_cli::{RemoteCmd, TuiCli, TuiCommand};
|
||||
use cbd_tui::config::{self, Config};
|
||||
use clap::{CommandFactory, Parser};
|
||||
|
||||
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;
|
||||
/// The config file name for the standalone TUI.
|
||||
const CONFIG_FILE: &str = "cbd-tui.toml";
|
||||
|
||||
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 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");
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
}
|
||||
}
|
||||
/// 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(())
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
/// 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),
|
||||
}
|
||||
|
||||
// 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
|
|
@ -1,16 +1,21 @@
|
|||
use crabidy_core::proto::crabidy::{
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||
|
||||
use base64::Engine;
|
||||
use tonic::{
|
||||
metadata::MetadataValue,
|
||||
service::{interceptor::InterceptedService, Interceptor},
|
||||
transport::{Channel, Endpoint},
|
||||
Request, Streaming,
|
||||
Request, Status, Streaming,
|
||||
};
|
||||
|
||||
// FIXME: use anyhow + thiserror
|
||||
|
|
@ -29,16 +34,78 @@ 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: CrabidyServiceClient<Channel>,
|
||||
client: Client,
|
||||
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(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
|
||||
let endpoint = Endpoint::from_static(addr).connect_lazy();
|
||||
let mut client = CrabidyServiceClient::new(endpoint);
|
||||
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);
|
||||
|
||||
let update_stream = Self::get_update_stream(&mut client).await;
|
||||
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
|
||||
|
|
@ -50,9 +117,7 @@ impl RpcClient {
|
|||
})
|
||||
}
|
||||
|
||||
async fn get_update_stream(
|
||||
client: &mut CrabidyServiceClient<Channel>,
|
||||
) -> Streaming<GetUpdateStreamResponse> {
|
||||
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
|
||||
loop {
|
||||
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
|
||||
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
|
||||
|
|
@ -75,45 +140,126 @@ impl RpcClient {
|
|||
|
||||
pub async fn get_library_node(
|
||||
&mut self,
|
||||
uuid: &str,
|
||||
path: &str,
|
||||
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
|
||||
if self.library_node_cache.contains_key(uuid) {
|
||||
return Ok(self.library_node_cache.get(uuid));
|
||||
if is_cacheable(path) && self.library_node_cache.contains_key(path) {
|
||||
return Ok(self.library_node_cache.get(path));
|
||||
}
|
||||
let get_library_node_request = Request::new(GetLibraryNodeRequest {
|
||||
uuid: uuid.to_string(),
|
||||
path: path.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(uuid.to_string(), library_node);
|
||||
return Ok(self.library_node_cache.get(uuid));
|
||||
.insert(path.to_string(), library_node);
|
||||
return Ok(self.library_node_cache.get(path));
|
||||
}
|
||||
Err(Box::new(RpcClientError::NotFound))
|
||||
}
|
||||
|
||||
pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let append_request = Request::new(AppendRequest { uuids });
|
||||
/// 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 });
|
||||
self.client.append(append_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let queue_request = Request::new(QueueRequest { uuids });
|
||||
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let queue_request = Request::new(QueueRequest { paths });
|
||||
self.client.queue(queue_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_tracks(
|
||||
&mut self,
|
||||
uuids: Vec<String>,
|
||||
paths: Vec<String>,
|
||||
pos: usize,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let insert_request = Request::new(InsertRequest {
|
||||
uuids,
|
||||
paths,
|
||||
position: pos as u32,
|
||||
});
|
||||
self.client.insert(insert_request).await?;
|
||||
|
|
@ -134,8 +280,56 @@ impl RpcClient {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let replace_request = Request::new(ReplaceRequest { uuids });
|
||||
/// 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 });
|
||||
self.client.replace(replace_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -158,6 +352,17 @@ 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,
|
||||
|
|
@ -172,6 +377,14 @@ 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?;
|
||||
|
|
@ -196,3 +409,54 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
//! 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
//! 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/dist
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
[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",
|
||||
] }
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# 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`).
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# 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"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!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
|
|
@ -0,0 +1,651 @@
|
|||
//! 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
//! 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
//! 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,975 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
[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
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
//! `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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,36 @@
|
|||
[package]
|
||||
name = "crabidy-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
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"] }
|
||||
|
||||
[build-dependencies]
|
||||
async-trait = "0.1.68"
|
||||
serde = "1.0.163"
|
||||
tonic-build = "0.9"
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_build::compile_protos("crabidy/v1/crabidy.proto")?;
|
||||
// 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"], &["."])?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,32 @@ 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);
|
||||
|
|
@ -15,6 +41,14 @@ 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);
|
||||
|
|
@ -29,6 +63,11 @@ 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
|
||||
|
|
@ -41,29 +80,78 @@ 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 uuid = 1;
|
||||
string path = 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 uuids = 1;
|
||||
repeated string paths = 1;
|
||||
}
|
||||
message QueueResponse {}
|
||||
|
||||
message ReplaceRequest {
|
||||
repeated string uuids = 1;
|
||||
repeated string paths = 1;
|
||||
}
|
||||
message ReplaceResponse {}
|
||||
|
||||
message AppendRequest {
|
||||
repeated string uuids = 1;
|
||||
repeated string paths = 1;
|
||||
}
|
||||
message AppendResponse {}
|
||||
|
||||
|
|
@ -73,8 +161,11 @@ 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 uuids = 2;
|
||||
repeated string paths = 2;
|
||||
}
|
||||
message InsertResponse {}
|
||||
|
||||
|
|
@ -99,6 +190,59 @@ 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 {
|
||||
|
|
@ -110,9 +254,43 @@ 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 {}
|
||||
|
|
@ -137,11 +315,40 @@ 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 uuid = 1;
|
||||
string path = 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 {
|
||||
|
|
@ -154,6 +361,10 @@ 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 {
|
||||
|
|
@ -181,20 +392,43 @@ message Album {
|
|||
}
|
||||
|
||||
message Track {
|
||||
// Including provider
|
||||
string uuid = 1;
|
||||
// Full library path including provider
|
||||
string path = 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 {
|
||||
// Including provider
|
||||
string uuid = 1;
|
||||
// Full library path including provider
|
||||
string path = 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::{
|
||||
fs::{create_dir_all, read_to_string, File},
|
||||
io::Write,
|
||||
|
|
@ -5,30 +6,134 @@ 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;
|
||||
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>;
|
||||
/// 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>;
|
||||
fn get_lib_root(&self) -> LibraryNode;
|
||||
async fn get_lib_node(&self, list_uuid: &str) -> Result<LibraryNode, ProviderError>;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum ProviderError {
|
||||
Config(String),
|
||||
UnknownUser,
|
||||
CouldNotLogin,
|
||||
FetchError,
|
||||
MalformedUuid,
|
||||
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,
|
||||
InternalError,
|
||||
Other,
|
||||
}
|
||||
|
|
@ -39,25 +144,95 @@ 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 {
|
||||
uuid: "node:/".to_string(),
|
||||
path: ROOT_PATH.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 {
|
||||
pub fn new(uuid: String, title: String, is_queable: bool) -> Self {
|
||||
/// 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 {
|
||||
Self {
|
||||
uuid,
|
||||
path,
|
||||
title,
|
||||
is_queable,
|
||||
is_creatable: false,
|
||||
is_editable: false,
|
||||
is_deletable: false,
|
||||
is_downloadable: false,
|
||||
is_captured: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +241,7 @@ 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,
|
||||
|
|
@ -94,3 +270,333 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,114 @@
|
|||
[package]
|
||||
name = "crabidy-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "crabidy-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
# 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"]
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,640 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
//! 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,568 @@
|
|||
//! 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
Loading…
Reference in New Issue