Compare commits
33 Commits
9706e3b5d2
...
ef69afdd5f
| Author | SHA1 | Date |
|---|---|---|
|
|
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 |
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
34
Cargo.toml
|
|
@ -2,10 +2,14 @@
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"audio-player",
|
"audio-player",
|
||||||
|
"cbd",
|
||||||
"cbd-tui",
|
"cbd-tui",
|
||||||
|
"cbd-web",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
|
"fsdy",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
|
"ytdy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
@ -14,18 +18,28 @@ edition = "2021"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
bytes = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
axum = "0.8"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap-serde-derive = "0.2"
|
clap-serde-derive = "0.2"
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
flume = "0.12"
|
flume = "0.12"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
|
http = "1"
|
||||||
|
include_dir = "0.7"
|
||||||
|
leptos = { version = "0.8", default-features = false, features = ["csr"] }
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
|
percent-encoding = "2"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
realfft = "3"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
reqwest = { version = "0.13", default-features = false, features = [
|
reqwest = { version = "0.13", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
|
|
@ -40,6 +54,10 @@ rodio = { version = "0.22", default-features = false, features = [
|
||||||
"playback",
|
"playback",
|
||||||
"symphonia-all",
|
"symphonia-all",
|
||||||
] }
|
] }
|
||||||
|
rustypipe = { version = "0.11", default-features = false, features = [
|
||||||
|
"rustls-tls-webpki-roots",
|
||||||
|
"userdata",
|
||||||
|
] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
|
|
@ -48,13 +66,23 @@ stream-download = { version = "0.24", default-features = false, features = [
|
||||||
"reqwest-rustls",
|
"reqwest-rustls",
|
||||||
"temp-storage",
|
"temp-storage",
|
||||||
] }
|
] }
|
||||||
|
tempfile = "3"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
tonic = "0.14"
|
# 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 = "0.14"
|
||||||
tonic-prost-build = "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 = "0.1"
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
@ -62,5 +90,9 @@ url = "2"
|
||||||
|
|
||||||
# Local crates
|
# Local crates
|
||||||
audio-player = { path = "audio-player" }
|
audio-player = { path = "audio-player" }
|
||||||
|
cbd-tui = { path = "cbd-tui" }
|
||||||
crabidy-core = { path = "crabidy-core" }
|
crabidy-core = { path = "crabidy-core" }
|
||||||
|
crabidy-server = { path = "crabidy-server" }
|
||||||
|
fsdy = { path = "fsdy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
|
ytdy = { path = "ytdy" }
|
||||||
|
|
|
||||||
189
README.md
189
README.md
|
|
@ -1 +1,190 @@
|
||||||
# crabidy
|
# 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
|
||||||
|
/
|
||||||
|
├── tidal Tidal streaming (see tidaldy/README.md)
|
||||||
|
├── youtube YouTube search & playlists (see ytdy/README.md)
|
||||||
|
├── fs a local music folder (see fsdy/README.md)
|
||||||
|
├── queues saved play queues (managed by the server)
|
||||||
|
├── bookmarks link snapshots of library subtrees (`w`)
|
||||||
|
└── captures downloaded snapshots with local audio (`W`)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
## 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 | Component | Documentation |
|
||||||
|
| -------------------- | ---------- | -------------------------------------- |
|
||||||
|
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
||||||
|
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
||||||
|
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
|
||||||
|
| `cbd-tui.toml` | `cbd-tui` | below |
|
||||||
|
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
|
||||||
|
| `crabidy-server.toml`| server | below (never auto-created) |
|
||||||
|
|
||||||
|
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
|
||||||
|
live in `~/.config/crabidy/`; they need no configuration and hold plain
|
||||||
|
folders of track files in the format documented in
|
||||||
|
[fsdy/README.md](fsdy/README.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
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Every option is also available as a command-line flag
|
||||||
|
(`cbd-tui --address ...`, `cbd --address ...`).
|
||||||
|
|
||||||
|
### `crabidy-server.toml` — roles and rights
|
||||||
|
|
||||||
|
By default the server is open: everyone who can reach the port has
|
||||||
|
full control. Adding an `[auth]` section turns on HTTP basic auth for
|
||||||
|
every RPC and hands out *roles* (see `architecture/roles-auth.md`):
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
# One PHC hash per role; omit a role to disable it. Generate with:
|
||||||
|
# crabidy-server hash-password (reads the password from stdin)
|
||||||
|
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` 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- `w` saves the selection as a **bookmark** (links; needs the source
|
||||||
|
provider to replay) or, in the queue pane, saves the queue.
|
||||||
|
- `W` **captures** the selection: the subtree is mirrored under
|
||||||
|
`/captures/<name>` with every track's audio downloaded next to its
|
||||||
|
metadata — fully local playback afterwards. In the queue pane `W`
|
||||||
|
captures the current queue directly (no need to save it first).
|
||||||
|
Tracks that are already local (from `/fs` or another capture) are
|
||||||
|
copied in rather than re-downloaded, so a queue mixing streamed and
|
||||||
|
local tracks captures completely. Captures are incremental:
|
||||||
|
re-capturing the same name resumes and completes it; tracks whose
|
||||||
|
source genuinely cannot be captured are recorded as *skipped* (red in
|
||||||
|
the UI, skipped by playback). Download captures can take long;
|
||||||
|
progress is shown in the library pane. Inside `/captures`, `d` deletes
|
||||||
|
any
|
||||||
|
folder or single track *from disk* (audio included) after a `y/N`
|
||||||
|
confirmation.
|
||||||
|
|
||||||
|
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).
|
||||||
|
Turn it off with `spectrum = false` in the client config.
|
||||||
|
|
||||||
|
## 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`, queue and playback
|
||||||
|
controls, `?` for help), plus clickable equivalents and 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).
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the server with `--no-default-features` for a headless,
|
||||||
|
gRPC-only binary. See [cbd-web/README.md](cbd-web/README.md) for the
|
||||||
|
dev loop and details.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Design documents live in `architecture/`, per-feature quality gates in
|
||||||
|
`quality/`, and implementation plans in `plan/`. See `CLAUDE.md` /
|
||||||
|
`AGENTS.md` for the development workflow and coding rules.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Bookmarks: capturing library subtrees
|
||||||
|
|
||||||
|
## 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,98 @@
|
||||||
|
# Capture deletion
|
||||||
|
|
||||||
|
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,228 @@
|
||||||
|
# Captures (downloaded subtrees)
|
||||||
|
|
||||||
|
> **Partially superseded** by `incremental-captures.md`: download captures
|
||||||
|
> are now incremental (no tmp-and-swap, re-capturing a name resumes it),
|
||||||
|
> uncapturable tracks are recorded as *skipped* tomls instead of being
|
||||||
|
> omitted, and the capture RPC streams progress. Bookmarks keep 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,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,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,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,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,269 @@
|
||||||
|
# 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 (sequentially, preserving
|
||||||
|
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 it.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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,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,154 @@
|
||||||
|
# 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.
|
||||||
|
- No auth configured (no `[auth]` section, or no hashes in it) means
|
||||||
|
the server behaves exactly as before: open, everyone is owner. Auth
|
||||||
|
switches on as soon as **any** role hash is configured; from then on
|
||||||
|
every RPC requires credentials.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Denied requests get `PERMISSION_DENIED`; missing or wrong credentials
|
||||||
|
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; omit a role to disable it.
|
||||||
|
# Generate with: crabidy-server hash-password
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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,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,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,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 on `UNAUTHENTICATED`, 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.
|
||||||
|
|
@ -5,7 +5,10 @@ edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
rodio.workspace = true
|
rodio.workspace = true
|
||||||
stream-download.workspace = true
|
stream-download.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
mod player;
|
mod player;
|
||||||
mod player_engine;
|
mod player_engine;
|
||||||
|
mod spectrum_tap;
|
||||||
|
pub mod windowed_http;
|
||||||
|
|
||||||
pub use player::{Player, PlayerError};
|
pub use player::{Player, PlayerError};
|
||||||
pub use player_engine::{MediaInfo, PlayerMessage};
|
pub use player_engine::{MediaInfo, PlayerMessage};
|
||||||
|
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,19 @@ use anyhow::Result;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
||||||
|
use crate::spectrum_tap::SpectrumTap;
|
||||||
|
|
||||||
pub enum PlayerError {}
|
pub enum PlayerError {}
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
pub messages: Receiver<PlayerMessage>,
|
pub messages: Receiver<PlayerMessage>,
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
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 {
|
impl Default for Player {
|
||||||
|
|
@ -25,8 +31,12 @@ impl Default for Player {
|
||||||
// tokio context but needs one to create http streams.
|
// tokio context but needs one to create http streams.
|
||||||
let runtime = tokio::runtime::Handle::try_current().ok();
|
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 || {
|
thread::spawn(move || {
|
||||||
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) {
|
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Could not initialize player: {}", e);
|
error!("Could not initialize player: {}", e);
|
||||||
return;
|
return;
|
||||||
|
|
@ -39,11 +49,17 @@ impl Default for Player {
|
||||||
Self {
|
Self {
|
||||||
messages,
|
messages,
|
||||||
tx_engine,
|
tx_engine,
|
||||||
|
spectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
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> {
|
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine
|
self.tx_engine
|
||||||
|
|
@ -108,6 +124,15 @@ impl Player {
|
||||||
Ok(rx.recv_async().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?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn pause(&self) -> Result<()> {
|
pub async fn pause(&self) -> Result<()> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine
|
self.tx_engine
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,28 @@ use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
|
||||||
use rodio::{Decoder, Source};
|
use rodio::{Decoder, Source};
|
||||||
use stream_download::storage::temp::TempStorageProvider;
|
use stream_download::storage::temp::TempStorageProvider;
|
||||||
use stream_download::{Settings, StreamDownload};
|
use stream_download::{Settings, StreamDownload};
|
||||||
|
|
||||||
|
use crate::spectrum_tap::{SpectrumTap, TappingSource};
|
||||||
|
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
||||||
|
use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, info, instrument, trace, warn};
|
use tracing::{debug, info, instrument, trace, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
/// How long we wait for the initial prefetch of a network stream.
|
/// How long we wait for the initial prefetch of a network stream.
|
||||||
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
|
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.
|
/// Interval between elapsed-position updates while playing.
|
||||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
|
@ -31,6 +47,7 @@ pub enum PlayerEngineCommand {
|
||||||
GetElapsed(Sender<Result<Duration>>),
|
GetElapsed(Sender<Result<Duration>>),
|
||||||
SeekTo(Duration, Sender<Result<Duration>>),
|
SeekTo(Duration, Sender<Result<Duration>>),
|
||||||
GetVolume(Sender<f32>),
|
GetVolume(Sender<f32>),
|
||||||
|
ToggleMute(Sender<bool>),
|
||||||
GetPaused(Sender<Result<bool>>),
|
GetPaused(Sender<Result<bool>>),
|
||||||
/// End of stream for the source started by the given generation.
|
/// End of stream for the source started by the given generation.
|
||||||
/// Stale generations are ignored so an old track finishing can never
|
/// Stale generations are ignored so an old track finishing can never
|
||||||
|
|
@ -78,6 +95,16 @@ pub struct PlayerEngine {
|
||||||
// Present when the engine had to bring its own runtime because the
|
// Present when the engine had to bring its own runtime because the
|
||||||
// creating thread was not inside a tokio context.
|
// creating thread was not inside a tokio context.
|
||||||
_owned_runtime: Option<tokio::runtime::Runtime>,
|
_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEngine {
|
impl PlayerEngine {
|
||||||
|
|
@ -85,6 +112,7 @@ impl PlayerEngine {
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
tx_engine: Sender<PlayerEngineCommand>,
|
||||||
tx_player: Sender<PlayerMessage>,
|
tx_player: Sender<PlayerMessage>,
|
||||||
runtime: Option<tokio::runtime::Handle>,
|
runtime: Option<tokio::runtime::Handle>,
|
||||||
|
spectrum: Arc<SpectrumTap>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let stream =
|
let stream =
|
||||||
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
||||||
|
|
@ -101,6 +129,10 @@ impl PlayerEngine {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!("audio output device opened");
|
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 {
|
Ok(Self {
|
||||||
current_source: None,
|
current_source: None,
|
||||||
media_info: None,
|
media_info: None,
|
||||||
|
|
@ -111,6 +143,10 @@ impl PlayerEngine {
|
||||||
tx_player,
|
tx_player,
|
||||||
runtime,
|
runtime,
|
||||||
_owned_runtime: owned_runtime,
|
_owned_runtime: owned_runtime,
|
||||||
|
http,
|
||||||
|
spectrum,
|
||||||
|
muted: false,
|
||||||
|
pre_mute_volume: 1.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +181,7 @@ impl PlayerEngine {
|
||||||
send_reply(tx, self.set_volume(volume));
|
send_reply(tx, self.set_volume(volume));
|
||||||
}
|
}
|
||||||
PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()),
|
PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()),
|
||||||
|
PlayerEngineCommand::ToggleMute(tx) => send_reply(tx, self.toggle_mute()),
|
||||||
PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()),
|
PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()),
|
||||||
PlayerEngineCommand::Eos(generation) => self.handle_eos(generation),
|
PlayerEngineCommand::Eos(generation) => self.handle_eos(generation),
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +204,7 @@ impl PlayerEngine {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip_all, fields(source = %display_source(source_str)))]
|
||||||
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
||||||
self.reset();
|
self.reset();
|
||||||
|
|
||||||
|
|
@ -194,12 +231,19 @@ impl PlayerEngine {
|
||||||
self.generation += 1;
|
self.generation += 1;
|
||||||
let duration = match Url::parse(source_str) {
|
let duration = match Url::parse(source_str) {
|
||||||
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
||||||
trace!(%url, "opening network stream");
|
trace!(
|
||||||
|
host = url.host_str().unwrap_or("?"),
|
||||||
|
"opening network stream"
|
||||||
|
);
|
||||||
|
// Windowed fetching: some CDNs (googlevideo) reject plain
|
||||||
|
// and open-ended requests with 403 and only serve bounded
|
||||||
|
// ranges (see audio-player/src/windowed_http.rs).
|
||||||
|
let params = WindowedHttpParams::new(url.clone(), self.http.clone());
|
||||||
let reader = self.runtime.block_on(async {
|
let reader = self.runtime.block_on(async {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
STREAM_OPEN_TIMEOUT,
|
STREAM_OPEN_TIMEOUT,
|
||||||
StreamDownload::new_http(
|
StreamDownload::new::<WindowedHttpStream>(
|
||||||
url.clone(),
|
params,
|
||||||
TempStorageProvider::new(),
|
TempStorageProvider::new(),
|
||||||
Settings::default(),
|
Settings::default(),
|
||||||
),
|
),
|
||||||
|
|
@ -208,14 +252,25 @@ impl PlayerEngine {
|
||||||
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
|
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
|
||||||
.context("failed to open http stream")
|
.context("failed to open http stream")
|
||||||
})?;
|
})?;
|
||||||
|
// 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.
|
||||||
|
let byte_len = reader.content_length();
|
||||||
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
||||||
|
if let Some(len) = byte_len {
|
||||||
|
builder = builder.with_byte_len(len);
|
||||||
|
}
|
||||||
if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str())
|
if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str())
|
||||||
{
|
{
|
||||||
builder = builder.with_hint(extension);
|
builder = builder.with_hint(extension);
|
||||||
}
|
}
|
||||||
let decoder = builder.build().context("failed to decode http stream")?;
|
let decoder = builder.build().context("failed to decode http stream")?;
|
||||||
let duration = decoder.total_duration();
|
let duration = decoder.total_duration();
|
||||||
self.sink.append(decoder);
|
// Mirror the played audio into the spectrum tap (it only
|
||||||
|
// observes; playback is unaffected).
|
||||||
|
self.sink
|
||||||
|
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
||||||
duration
|
duration
|
||||||
}
|
}
|
||||||
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
||||||
|
|
@ -236,7 +291,8 @@ impl PlayerEngine {
|
||||||
}
|
}
|
||||||
let decoder = builder.build().context("failed to decode file")?;
|
let decoder = builder.build().context("failed to decode file")?;
|
||||||
let duration = decoder.total_duration();
|
let duration = decoder.total_duration();
|
||||||
self.sink.append(decoder);
|
self.sink
|
||||||
|
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
||||||
duration
|
duration
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -338,13 +394,37 @@ impl PlayerEngine {
|
||||||
Ok(self.sink.get_pos())
|
Ok(self.sink.get_pos())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub fn volume(&self) -> f32 {
|
||||||
|
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()
|
self.sink.volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_volume(&mut self, volume: f32) -> f32 {
|
/// Toggles mute by zeroing the sink volume and remembering the level
|
||||||
self.sink.set_volume(volume.clamp(0.0, 1.1));
|
/// to restore. Returns the new muted state.
|
||||||
self.sink.volume()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_eos(&mut self, generation: u64) {
|
fn handle_eos(&mut self, generation: u64) {
|
||||||
|
|
@ -380,3 +460,22 @@ fn send_reply<T>(tx: Sender<T>, value: T) {
|
||||||
warn!("player engine reply receiver dropped");
|
warn!("player engine reply receiver dropped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
|
@ -13,7 +14,7 @@ ratatui.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tokio-stream.workspace = true
|
tokio-stream.workspace = true
|
||||||
tonic.workspace = true
|
tonic = { workspace = true, features = ["channel", "codegen"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,851 @@
|
||||||
|
//! Declarative keyboard bindings: the single source of truth for both key
|
||||||
|
//! dispatch (`lookup`) and the help modal (which renders [`BINDINGS`]).
|
||||||
|
//!
|
||||||
|
//! Pure data and pure functions — no I/O, no ratatui types — so the whole
|
||||||
|
//! table is unit-testable without a terminal.
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use super::UiFocus;
|
||||||
|
|
||||||
|
/// Where a binding applies.
|
||||||
|
///
|
||||||
|
/// `Global` entries match regardless of which pane is focused. `Library` and
|
||||||
|
/// `Queue` entries match only while that pane is focused. `Help` entries
|
||||||
|
/// match only while the help modal is open — while it is open, *no* other
|
||||||
|
/// scope matches (the modal is strictly modal).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Scope {
|
||||||
|
Global,
|
||||||
|
Library,
|
||||||
|
Queue,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every user-visible operation a key press can trigger.
|
||||||
|
///
|
||||||
|
/// Pane-specific variants are prefixed with their pane so the enum stays
|
||||||
|
/// collision-free as panes grow.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
// Global
|
||||||
|
/// Open the help modal (closing it is `CloseHelp`, scope `Help`).
|
||||||
|
OpenHelp,
|
||||||
|
Quit,
|
||||||
|
CycleFocus,
|
||||||
|
TogglePlay,
|
||||||
|
RestartTrack,
|
||||||
|
VolumeUp,
|
||||||
|
VolumeDown,
|
||||||
|
ToggleMute,
|
||||||
|
ToggleShuffle,
|
||||||
|
ToggleRepeat,
|
||||||
|
NextTrack,
|
||||||
|
PrevTrack,
|
||||||
|
// Library pane
|
||||||
|
LibraryFirst,
|
||||||
|
LibraryLast,
|
||||||
|
LibraryNext,
|
||||||
|
LibraryPrev,
|
||||||
|
LibraryJumpDown,
|
||||||
|
LibraryJumpUp,
|
||||||
|
LibraryAscend,
|
||||||
|
LibraryDive,
|
||||||
|
LibraryQueueNext,
|
||||||
|
LibraryQueueAppend,
|
||||||
|
LibraryQueueReplace,
|
||||||
|
LibraryToggleMark,
|
||||||
|
/// Open the input overlay to create a child of the currently open
|
||||||
|
/// library node. No-op unless that node `is_creatable` (e.g.
|
||||||
|
/// /tidal/search). While the overlay is open, keys bypass this table
|
||||||
|
/// entirely (see `App::handle_input_key`).
|
||||||
|
LibraryCreateNode,
|
||||||
|
/// Open the input overlay prefilled with the selected item's title to
|
||||||
|
/// rename it. No-op unless the selection `is_editable` (e.g. a search
|
||||||
|
/// term, whose rename re-runs the search).
|
||||||
|
LibraryEditNode,
|
||||||
|
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
||||||
|
/// Cheap deletables (search terms, bookmarks, saved queues) delete
|
||||||
|
/// unconfirmed (architecture/node-editing.md, D4); captures hold
|
||||||
|
/// downloaded audio and open a y/N confirmation instead
|
||||||
|
/// (architecture/capture-deletion.md).
|
||||||
|
LibraryDeleteNode,
|
||||||
|
/// Open the input overlay (prefilled with the selection's title) to
|
||||||
|
/// capture the selected queueable subtree as a bookmark under
|
||||||
|
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
|
||||||
|
LibraryCaptureNode,
|
||||||
|
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
||||||
|
/// every track's audio into `/captures`. No-op unless the bare
|
||||||
|
/// selection is queueable *and* downloadable (e.g. Tidal subtrees).
|
||||||
|
LibraryDownloadNode,
|
||||||
|
/// Open the `/` search input for the focused pane. Typing filters the
|
||||||
|
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
||||||
|
/// filter, `Esc` clears it. Bound in both the library and queue
|
||||||
|
/// scopes; the dispatch targets whichever pane has focus.
|
||||||
|
OpenSearch,
|
||||||
|
// Queue pane
|
||||||
|
QueueInsertHere,
|
||||||
|
QueueFirst,
|
||||||
|
QueueLast,
|
||||||
|
QueueNext,
|
||||||
|
QueuePrev,
|
||||||
|
QueueJumpDown,
|
||||||
|
QueueJumpUp,
|
||||||
|
QueueSelectCurrent,
|
||||||
|
QueuePlaySelected,
|
||||||
|
QueueRemoveTrack,
|
||||||
|
QueueClearKeepCurrent,
|
||||||
|
QueueClearAll,
|
||||||
|
/// Open the input overlay asking for a name to save the queue under
|
||||||
|
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
||||||
|
QueueSaveAs,
|
||||||
|
/// Open the input overlay to **download-capture** the current queue
|
||||||
|
/// straight into `/captures/<name>` (the queue equivalent of the
|
||||||
|
/// library's `W`), instead of having to save it and then capture the
|
||||||
|
/// saved copy. No-op while the queue is empty.
|
||||||
|
QueueDownloadCapture,
|
||||||
|
// Help modal
|
||||||
|
CloseHelp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One key binding: a key chord, the scope it applies in, the action it
|
||||||
|
/// triggers, and the human-readable description the help modal shows.
|
||||||
|
pub struct Binding {
|
||||||
|
pub scope: Scope,
|
||||||
|
pub mods: KeyModifiers,
|
||||||
|
pub code: KeyCode,
|
||||||
|
pub action: Action,
|
||||||
|
/// Shown verbatim in the help modal. Imperative mood, no trailing period.
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All bindings, in help-modal display order (grouped by scope; `Global`
|
||||||
|
/// first, then `Library`, `Queue`, `Help`).
|
||||||
|
///
|
||||||
|
/// Invariant: within one scope, each `(mods, code)` chord appears at most
|
||||||
|
/// once, except that shifted characters may carry either `SHIFT` or `NONE`
|
||||||
|
/// (terminals disagree); `lookup` must treat those as equal for `Char` codes.
|
||||||
|
pub const BINDINGS: &[Binding] = &[
|
||||||
|
// -- Global ----------------------------------------------------------
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('?'),
|
||||||
|
action: Action::OpenHelp,
|
||||||
|
description: "Show this help",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('q'),
|
||||||
|
action: Action::Quit,
|
||||||
|
description: "Quit",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Tab,
|
||||||
|
action: Action::CycleFocus,
|
||||||
|
description: "Switch between library and queue",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char(' '),
|
||||||
|
action: Action::TogglePlay,
|
||||||
|
description: "Play/pause",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('r'),
|
||||||
|
action: Action::RestartTrack,
|
||||||
|
description: "Restart current track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('K'),
|
||||||
|
action: Action::VolumeUp,
|
||||||
|
description: "Volume up",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('J'),
|
||||||
|
action: Action::VolumeDown,
|
||||||
|
description: "Volume down",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('m'),
|
||||||
|
action: Action::ToggleMute,
|
||||||
|
description: "Toggle mute",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('z'),
|
||||||
|
action: Action::ToggleShuffle,
|
||||||
|
description: "Toggle shuffle",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('x'),
|
||||||
|
action: Action::ToggleRepeat,
|
||||||
|
description: "Toggle repeat",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::CONTROL,
|
||||||
|
code: KeyCode::Char('n'),
|
||||||
|
action: Action::NextTrack,
|
||||||
|
description: "Next track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::CONTROL,
|
||||||
|
code: KeyCode::Char('p'),
|
||||||
|
action: Action::PrevTrack,
|
||||||
|
description: "Previous track",
|
||||||
|
},
|
||||||
|
// -- Library ---------------------------------------------------------
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('j'),
|
||||||
|
action: Action::LibraryNext,
|
||||||
|
description: "Select next item",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('k'),
|
||||||
|
action: Action::LibraryPrev,
|
||||||
|
description: "Select previous item",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('g'),
|
||||||
|
action: Action::LibraryFirst,
|
||||||
|
description: "Select first item",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('G'),
|
||||||
|
action: Action::LibraryLast,
|
||||||
|
description: "Select last item",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::CONTROL,
|
||||||
|
code: KeyCode::Char('d'),
|
||||||
|
action: Action::LibraryJumpDown,
|
||||||
|
description: "Jump 15 items down",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::CONTROL,
|
||||||
|
code: KeyCode::Char('u'),
|
||||||
|
action: Action::LibraryJumpUp,
|
||||||
|
description: "Jump 15 items up",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('h'),
|
||||||
|
action: Action::LibraryAscend,
|
||||||
|
description: "Go to parent folder",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('l'),
|
||||||
|
action: Action::LibraryDive,
|
||||||
|
description: "Enter selected folder",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('s'),
|
||||||
|
action: Action::LibraryToggleMark,
|
||||||
|
description: "Mark/unmark selection",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('w'),
|
||||||
|
action: Action::LibraryCaptureNode,
|
||||||
|
description: "Save selection as bookmark",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('W'),
|
||||||
|
action: Action::LibraryDownloadNode,
|
||||||
|
description: "Download selection as capture (can take long; same name resumes)",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('%'),
|
||||||
|
action: Action::LibraryCreateNode,
|
||||||
|
description: "Create node here (e.g. search term)",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('e'),
|
||||||
|
action: Action::LibraryEditNode,
|
||||||
|
description: "Rename selected node (e.g. search term)",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('d'),
|
||||||
|
action: Action::LibraryDeleteNode,
|
||||||
|
description: "Delete selection (captures ask y/N, and delete files)",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('a'),
|
||||||
|
action: Action::LibraryQueueAppend,
|
||||||
|
description: "Append selection to queue",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('L'),
|
||||||
|
action: Action::LibraryQueueNext,
|
||||||
|
description: "Queue selection after current track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Enter,
|
||||||
|
action: Action::LibraryQueueReplace,
|
||||||
|
description: "Replace queue with selection",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('/'),
|
||||||
|
action: Action::OpenSearch,
|
||||||
|
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
||||||
|
},
|
||||||
|
// -- Queue -----------------------------------------------------------
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('j'),
|
||||||
|
action: Action::QueueNext,
|
||||||
|
description: "Select next track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('k'),
|
||||||
|
action: Action::QueuePrev,
|
||||||
|
description: "Select previous track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('g'),
|
||||||
|
action: Action::QueueFirst,
|
||||||
|
description: "Select first track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('G'),
|
||||||
|
action: Action::QueueLast,
|
||||||
|
description: "Select last track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::CONTROL,
|
||||||
|
code: KeyCode::Char('d'),
|
||||||
|
action: Action::QueueJumpDown,
|
||||||
|
description: "Jump 15 tracks down",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::CONTROL,
|
||||||
|
code: KeyCode::Char('u'),
|
||||||
|
action: Action::QueueJumpUp,
|
||||||
|
description: "Jump 15 tracks up",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('o'),
|
||||||
|
action: Action::QueueSelectCurrent,
|
||||||
|
description: "Select the playing track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Enter,
|
||||||
|
action: Action::QueuePlaySelected,
|
||||||
|
description: "Play selected track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('p'),
|
||||||
|
action: Action::QueueInsertHere,
|
||||||
|
description: "Insert library selection after this track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('d'),
|
||||||
|
action: Action::QueueRemoveTrack,
|
||||||
|
description: "Remove selected track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('c'),
|
||||||
|
action: Action::QueueClearKeepCurrent,
|
||||||
|
description: "Clear queue except current track",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('C'),
|
||||||
|
action: Action::QueueClearAll,
|
||||||
|
description: "Clear entire queue",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('w'),
|
||||||
|
action: Action::QueueSaveAs,
|
||||||
|
description: "Save queue under a name",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('W'),
|
||||||
|
action: Action::QueueDownloadCapture,
|
||||||
|
description: "Capture the queue (download audio; same name resumes)",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('/'),
|
||||||
|
action: Action::OpenSearch,
|
||||||
|
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
||||||
|
},
|
||||||
|
// -- Help modal ------------------------------------------------------
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Help,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('?'),
|
||||||
|
action: Action::CloseHelp,
|
||||||
|
description: "Close help",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Help,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Esc,
|
||||||
|
action: Action::CloseHelp,
|
||||||
|
description: "Close help",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Help,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('q'),
|
||||||
|
action: Action::CloseHelp,
|
||||||
|
description: "Close help",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Translate a key event into an action, honoring modality and focus.
|
||||||
|
///
|
||||||
|
/// While `help_open` is true only `Scope::Help` entries can match — every
|
||||||
|
/// other key press is swallowed. Otherwise `Scope::Global` entries match in
|
||||||
|
/// any focus and pane entries match only their own `UiFocus`.
|
||||||
|
///
|
||||||
|
/// For `KeyCode::Char` codes the `SHIFT` modifier is ignored during
|
||||||
|
/// comparison (terminals disagree on whether shifted characters like `?` or
|
||||||
|
/// `G` report `SHIFT`); all other modifiers must match exactly.
|
||||||
|
pub fn lookup(focus: UiFocus, help_open: bool, key: KeyEvent) -> Option<Action> {
|
||||||
|
let scope_matches = |scope: Scope| match (help_open, scope) {
|
||||||
|
(true, s) => s == Scope::Help,
|
||||||
|
(false, Scope::Help) => false,
|
||||||
|
(false, Scope::Global) => true,
|
||||||
|
(false, Scope::Library) => matches!(focus, UiFocus::Library),
|
||||||
|
(false, Scope::Queue) => matches!(focus, UiFocus::Queue),
|
||||||
|
};
|
||||||
|
let chord_matches = |b: &Binding| {
|
||||||
|
if b.code != key.code {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if matches!(b.code, KeyCode::Char(_)) {
|
||||||
|
b.mods.difference(KeyModifiers::SHIFT) == key.modifiers.difference(KeyModifiers::SHIFT)
|
||||||
|
} else {
|
||||||
|
b.mods == key.modifiers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
BINDINGS
|
||||||
|
.iter()
|
||||||
|
.find(|b| scope_matches(b.scope) && chord_matches(b))
|
||||||
|
.map(|b| b.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for a binding's key chord, e.g. `"Space"`,
|
||||||
|
/// `"Ctrl+d"`, `"?"`. Used by the help modal so displayed keys are derived
|
||||||
|
/// from the same data dispatch uses.
|
||||||
|
pub fn key_label(mods: KeyModifiers, code: KeyCode) -> String {
|
||||||
|
let key = match code {
|
||||||
|
KeyCode::Char(' ') => "Space".to_string(),
|
||||||
|
KeyCode::Char(c) => c.to_string(),
|
||||||
|
KeyCode::Tab => "Tab".to_string(),
|
||||||
|
KeyCode::Enter => "Enter".to_string(),
|
||||||
|
KeyCode::Esc => "Esc".to_string(),
|
||||||
|
other => format!("{other:?}"),
|
||||||
|
};
|
||||||
|
if mods.contains(KeyModifiers::CONTROL) {
|
||||||
|
format!("Ctrl+{key}")
|
||||||
|
} else {
|
||||||
|
// SHIFT is already visible in the character itself ('K', '?', …).
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_bindings_match_in_any_focus() {
|
||||||
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, false, key(KeyCode::Char('?'), KeyModifiers::NONE)),
|
||||||
|
Some(Action::OpenHelp)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, false, key(KeyCode::Char('q'), KeyModifiers::NONE)),
|
||||||
|
Some(Action::Quit)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, false, key(KeyCode::Tab, KeyModifiers::NONE)),
|
||||||
|
Some(Action::CycleFocus)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, false, key(KeyCode::Char(' '), KeyModifiers::NONE)),
|
||||||
|
Some(Action::TogglePlay)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_bindings_only_match_their_own_pane() {
|
||||||
|
// 'h' ascends in the library but is unbound in the queue.
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('h'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryAscend)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('h'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// 'c' clears the queue there but is unbound in the library.
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('c'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::QueueClearKeepCurrent)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('c'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn w_saves_the_queue_or_captures_the_selection_per_pane() {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::QueueSaveAs)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryCaptureNode)
|
||||||
|
);
|
||||||
|
// Shift-w is the download capture in each pane: the library
|
||||||
|
// selection, or the whole queue.
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('W'), KeyModifiers::SHIFT)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryDownloadNode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('W'), KeyModifiers::SHIFT)
|
||||||
|
),
|
||||||
|
Some(Action::QueueDownloadCapture)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_chord_resolves_per_pane() {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('j'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('j'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::QueueNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Enter, KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryQueueReplace)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Enter, KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::QueuePlaySelected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_is_ignored_for_char_codes() {
|
||||||
|
// Terminals disagree on whether shifted characters report SHIFT.
|
||||||
|
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(KeyCode::Char('?'), mods)),
|
||||||
|
Some(Action::OpenHelp)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(KeyCode::Char('G'), mods)),
|
||||||
|
Some(Action::LibraryLast)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('K'), mods)),
|
||||||
|
Some(Action::VolumeUp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percent_creates_only_in_the_library() {
|
||||||
|
// '%' is shifted on most layouts; both modifier reports must match.
|
||||||
|
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(KeyCode::Char('%'), mods)),
|
||||||
|
Some(Action::LibraryCreateNode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('%'), mods)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_must_match_exactly() {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('n'), KeyModifiers::CONTROL)
|
||||||
|
),
|
||||||
|
Some(Action::NextTrack)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('n'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('d'), KeyModifiers::CONTROL)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryJumpDown)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_and_delete_bind_in_the_library_scope_only() {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('e'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryEditNode)
|
||||||
|
);
|
||||||
|
// Plain 'd' renames per scope: library delete vs queue remove-track;
|
||||||
|
// Ctrl+d stays the jump (see control_must_match_exactly).
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('d'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::LibraryDeleteNode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('d'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::QueueRemoveTrack)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('e'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_help_swallows_everything_but_close() {
|
||||||
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||||
|
// The three close chords work...
|
||||||
|
for code in [KeyCode::Char('?'), KeyCode::Esc, KeyCode::Char('q')] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, true, key(code, KeyModifiers::NONE)),
|
||||||
|
Some(Action::CloseHelp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ...and every other binding is inert while help is open.
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, true, key(KeyCode::Char(' '), KeyModifiers::NONE)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, true, key(KeyCode::Char('j'), KeyModifiers::NONE)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, true, key(KeyCode::Tab, KeyModifiers::NONE)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_scope_never_matches_while_help_is_closed() {
|
||||||
|
// Esc is only bound inside the modal.
|
||||||
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chords_are_unique_within_scope() {
|
||||||
|
// Normalize SHIFT away for Char codes, mirroring lookup's comparison.
|
||||||
|
fn norm(b: &Binding) -> (Scope, KeyModifiers, KeyCode) {
|
||||||
|
let mods = if matches!(b.code, KeyCode::Char(_)) {
|
||||||
|
b.mods.difference(KeyModifiers::SHIFT)
|
||||||
|
} else {
|
||||||
|
b.mods
|
||||||
|
};
|
||||||
|
(b.scope, mods, b.code)
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for b in BINDINGS {
|
||||||
|
assert!(
|
||||||
|
seen.insert(norm(b)),
|
||||||
|
"duplicate chord in scope {:?}: {:?}+{:?}",
|
||||||
|
b.scope,
|
||||||
|
b.mods,
|
||||||
|
b.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_binding_has_a_description() {
|
||||||
|
for b in BINDINGS {
|
||||||
|
assert!(
|
||||||
|
!b.description.trim().is_empty(),
|
||||||
|
"empty description for {:?}",
|
||||||
|
b.action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_labels_are_human_readable() {
|
||||||
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q");
|
||||||
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('?')), "?");
|
||||||
|
assert_eq!(key_label(KeyModifiers::SHIFT, KeyCode::Char('K')), "K");
|
||||||
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char(' ')), "Space");
|
||||||
|
assert_eq!(
|
||||||
|
key_label(KeyModifiers::CONTROL, KeyCode::Char('d')),
|
||||||
|
"Ctrl+d"
|
||||||
|
);
|
||||||
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Tab), "Tab");
|
||||||
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Enter), "Enter");
|
||||||
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Esc), "Esc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
//! 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 (Global left,
|
||||||
|
/// Library + Queue right, so the whole table fits a typical frame), and a
|
||||||
|
/// close-keys footer derived from the `Scope::Help` bindings.
|
||||||
|
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 left = group(Scope::Global, "Global");
|
||||||
|
|
||||||
|
let mut right = group(Scope::Library, "Library");
|
||||||
|
right.push(Line::from(""));
|
||||||
|
right.extend(group(Scope::Queue, "Queue"));
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
let text = buffer_text(&render_to_buffer(100, 40));
|
||||||
|
// 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("Close help"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,14 +12,21 @@ use ratatui::{
|
||||||
use crabidy_core::proto::crabidy::LibraryNode;
|
use crabidy_core::proto::crabidy::LibraryNode;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
|
||||||
|
COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
title: String,
|
title: String,
|
||||||
path: 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: Vec<UiItem>,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
|
/// The `/` search filter; selection and rendering go through it.
|
||||||
|
filter: Filter,
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
positions: HashMap<String, usize>,
|
positions: HashMap<String, usize>,
|
||||||
tx: Sender<MessageFromUi>,
|
tx: Sender<MessageFromUi>,
|
||||||
|
|
@ -30,14 +37,76 @@ impl Library {
|
||||||
Self {
|
Self {
|
||||||
title: "Library".to_string(),
|
title: "Library".to_string(),
|
||||||
path: crabidy_core::ROOT_PATH.to_string(),
|
path: crabidy_core::ROOT_PATH.to_string(),
|
||||||
|
is_creatable: false,
|
||||||
list: Vec::new(),
|
list: Vec::new(),
|
||||||
list_state: ListState::default(),
|
list_state: ListState::default(),
|
||||||
|
filter: Filter::default(),
|
||||||
positions: HashMap::new(),
|
positions: HashMap::new(),
|
||||||
parent: None,
|
parent: None,
|
||||||
tx,
|
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()))
|
||||||
|
}
|
||||||
pub fn get_selected(&self) -> Option<Vec<String>> {
|
pub fn get_selected(&self) -> Option<Vec<String>> {
|
||||||
|
// Marks live on the full list; a hidden marked item still counts
|
||||||
|
// (the filter narrows what you *see*, not what you already chose).
|
||||||
if self.list.iter().any(|i| i.marked) {
|
if self.list.iter().any(|i| i.marked) {
|
||||||
return Some(
|
return Some(
|
||||||
self.list
|
self.list
|
||||||
|
|
@ -47,10 +116,12 @@ impl Library {
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(idx) = self.list_state.selected() {
|
// Marks are gated on is_queable when set; the bare selection must be
|
||||||
return Some(vec![self.list[idx].path.to_string()]);
|
// gated here too, or Enter on a plain folder ships a path the server
|
||||||
}
|
// can only resolve to nothing (silently ignored, like % / e / d on
|
||||||
None
|
// items without the capability).
|
||||||
|
let item = self.resolved()?;
|
||||||
|
item.is_queable.then(|| vec![item.path.to_string()])
|
||||||
}
|
}
|
||||||
pub fn ascend(&mut self) {
|
pub fn ascend(&mut self) {
|
||||||
if let Some(parent) = self.parent.as_ref() {
|
if let Some(parent) = self.parent.as_ref() {
|
||||||
|
|
@ -58,8 +129,7 @@ impl Library {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn dive(&mut self) {
|
pub fn dive(&mut self) {
|
||||||
if let Some(idx) = self.list_state.selected() {
|
if let Some(item) = self.resolved() {
|
||||||
let item = &self.list[idx];
|
|
||||||
if let UiItemKind::Node = item.kind {
|
if let UiItemKind::Node = item.kind {
|
||||||
let _ = self
|
let _ = self
|
||||||
.tx
|
.tx
|
||||||
|
|
@ -103,8 +173,12 @@ impl Library {
|
||||||
*self.positions.get(&self.path).unwrap_or(&0)
|
*self.positions.get(&self.path).unwrap_or(&0)
|
||||||
}
|
}
|
||||||
pub fn toggle_mark(&mut self) {
|
pub fn toggle_mark(&mut self) {
|
||||||
if let Some(idx) = self.list_state.selected() {
|
if let Some(real) = self
|
||||||
let item = &mut self.list[idx];
|
.list_state
|
||||||
|
.selected()
|
||||||
|
.and_then(|view| self.filter.to_real(view))
|
||||||
|
{
|
||||||
|
let item = &mut self.list[real];
|
||||||
if !item.is_queable {
|
if !item.is_queable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +194,9 @@ impl Library {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn update(&mut self, node: LibraryNode) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,9 +204,12 @@ impl Library {
|
||||||
self.path = node.path;
|
self.path = node.path;
|
||||||
self.title = node.title;
|
self.title = node.title;
|
||||||
self.parent = node.parent;
|
self.parent = node.parent;
|
||||||
self.select(Some(self.prev_selected()));
|
self.is_creatable = node.is_creatable;
|
||||||
|
|
||||||
if !node.tracks.is_empty() {
|
// 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
|
self.list = node
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -140,43 +219,85 @@ impl Library {
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: false,
|
marked: false,
|
||||||
is_queable: true,
|
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,
|
||||||
})
|
})
|
||||||
.collect();
|
.chain(node.children.iter().map(|c| UiItem {
|
||||||
} else {
|
|
||||||
// if tracks not empty use tracks instead
|
|
||||||
self.list = node
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.map(|c| UiItem {
|
|
||||||
path: c.path.clone(),
|
path: c.path.clone(),
|
||||||
title: c.title.clone(),
|
title: c.title.clone(),
|
||||||
kind: UiItemKind::Node,
|
kind: UiItemKind::Node,
|
||||||
marked: false,
|
marked: false,
|
||||||
is_queable: c.is_queable,
|
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();
|
.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()));
|
||||||
self.update_selection();
|
self.update_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(&mut self, f: &mut Frame, 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
|
let library_items: Vec<ListItem> = self
|
||||||
.list
|
.filter
|
||||||
|
.visible()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|i| {
|
.map(|&real| &self.list[real])
|
||||||
let text = if i.marked {
|
.enumerate()
|
||||||
|
.map(|(idx, i)| {
|
||||||
|
let mut text = if i.marked {
|
||||||
format!("* {}", i.title)
|
format!("* {}", i.title)
|
||||||
} else {
|
} else {
|
||||||
i.title.to_string()
|
i.title.to_string()
|
||||||
};
|
};
|
||||||
let style = if i.marked {
|
if i.is_creatable {
|
||||||
|
text.push_str(" [%]");
|
||||||
|
}
|
||||||
|
// Modifiable items advertise their 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(']');
|
||||||
|
}
|
||||||
|
let mut style = if i.marked {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(COLOR_GREEN)
|
.fg(COLOR_GREEN)
|
||||||
.add_modifier(Modifier::BOLD)
|
.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 {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
// A colored foreground is unreadable on the light focused
|
||||||
|
// selection bar — switch it to the dark tone there (D7).
|
||||||
|
if focused && selected == Some(idx) && style.fg.is_some() {
|
||||||
|
style = style.fg(COLOR_PRIMARY_DARK);
|
||||||
|
}
|
||||||
ListItem::new(Span::from(text)).style(style)
|
ListItem::new(Span::from(text)).style(style)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -191,7 +312,14 @@ impl Library {
|
||||||
} else {
|
} else {
|
||||||
COLOR_PRIMARY_DARK
|
COLOR_PRIMARY_DARK
|
||||||
}))
|
}))
|
||||||
.title(self.title.clone()),
|
.title(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(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
@ -209,15 +337,21 @@ impl Library {
|
||||||
|
|
||||||
impl StatefulList for Library {
|
impl StatefulList for Library {
|
||||||
fn get_size(&self) -> usize {
|
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>) {
|
fn select(&mut self, idx: Option<usize>) {
|
||||||
if let Some(pos) = idx {
|
// 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
|
self.positions
|
||||||
.entry(self.path.clone())
|
.entry(self.path.clone())
|
||||||
.and_modify(|e| *e = pos)
|
.and_modify(|e| *e = real)
|
||||||
.or_insert(pos);
|
.or_insert(real);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.list_state.select(idx);
|
self.list_state.select(idx);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,75 @@
|
||||||
|
/// 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
|
// FIXME: Move marking stuff here, to be able to use it in queue as well
|
||||||
pub trait StatefulList {
|
pub trait StatefulList {
|
||||||
fn get_size(&self) -> usize;
|
fn get_size(&self) -> usize;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,7 +12,37 @@ use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::COLOR_SECONDARY;
|
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
|
||||||
|
|
||||||
|
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
|
||||||
|
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
|
||||||
|
/// Renders `bins` as full-height vertical bars filling a `width`×`height`
|
||||||
|
/// area: one `Line` per row, top row first. Each column maps to a bin;
|
||||||
|
/// its level in `[0, 1]` fills from the bottom, using partial block
|
||||||
|
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
|
||||||
|
fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static>> {
|
||||||
|
(0..height)
|
||||||
|
.map(|row| {
|
||||||
|
// Row 0 is the top; count cells up from the bottom.
|
||||||
|
let from_bottom = height - 1 - row;
|
||||||
|
let cells: String = (0..width)
|
||||||
|
.map(|col| {
|
||||||
|
let bin = if bins.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(col * bins.len() / width.max(1)).min(bins.len() - 1)
|
||||||
|
};
|
||||||
|
let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||||
|
let total_eighths = (level * height as f32 * 8.0).round() as usize;
|
||||||
|
let cell = total_eighths.saturating_sub(from_bottom * 8).min(8);
|
||||||
|
BLOCKS[cell]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub struct NowPlaying {
|
pub struct NowPlaying {
|
||||||
play_state: PlayState,
|
play_state: PlayState,
|
||||||
|
|
@ -20,6 +50,13 @@ pub struct NowPlaying {
|
||||||
modifiers: QueueModifiers,
|
modifiers: QueueModifiers,
|
||||||
position: Option<Duration>,
|
position: Option<Duration>,
|
||||||
track: Option<Track>,
|
track: Option<Track>,
|
||||||
|
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty
|
||||||
|
/// until the first frame arrives.
|
||||||
|
spectrum: Vec<f32>,
|
||||||
|
/// Whether to draw the spectrum row (config `spectrum`, default on).
|
||||||
|
spectrum_enabled: bool,
|
||||||
|
/// Whether the server output is muted.
|
||||||
|
muted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NowPlaying {
|
impl Default for NowPlaying {
|
||||||
|
|
@ -30,6 +67,9 @@ impl Default for NowPlaying {
|
||||||
modifiers: QueueModifiers::default(),
|
modifiers: QueueModifiers::default(),
|
||||||
position: None,
|
position: None,
|
||||||
track: None,
|
track: None,
|
||||||
|
spectrum: Vec::new(),
|
||||||
|
spectrum_enabled: true,
|
||||||
|
muted: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +97,11 @@ impl NowPlaying {
|
||||||
format!("{} by {}", track.title, track.artist,)
|
format!("{} by {}", track.title, track.artist,)
|
||||||
};
|
};
|
||||||
// A missing notification daemon must not crash the TUI.
|
// A missing notification daemon must not crash the TUI.
|
||||||
|
// The explicit appname keeps notification-daemon rules
|
||||||
|
// (e.g. mako `app-name=` criteria) stable even if the
|
||||||
|
// binary is renamed or wrapped.
|
||||||
if let Err(err) = Notification::new()
|
if let Err(err) = Notification::new()
|
||||||
|
.appname("crabidy")
|
||||||
.summary("Now playing")
|
.summary("Now playing")
|
||||||
.body(&body)
|
.body(&body)
|
||||||
.show()
|
.show()
|
||||||
|
|
@ -70,11 +114,39 @@ impl NowPlaying {
|
||||||
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
||||||
self.modifiers = *mods;
|
self.modifiers = *mods;
|
||||||
}
|
}
|
||||||
|
/// Applies a spectrum frame from the server (already normalized).
|
||||||
|
pub fn update_spectrum(&mut self, bins: Vec<f32>) {
|
||||||
|
self.spectrum = bins;
|
||||||
|
}
|
||||||
|
/// Enables/disables the spectrum row (from client config).
|
||||||
|
pub fn set_spectrum_enabled(&mut self, enabled: bool) {
|
||||||
|
self.spectrum_enabled = enabled;
|
||||||
|
}
|
||||||
|
/// Reflects the server's mute state.
|
||||||
|
pub fn update_mute(&mut self, muted: bool) {
|
||||||
|
self.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||||
|
// With the spectrum on, the info block takes exactly the height
|
||||||
|
// its content needs, the progress a fixed row, and the spectrum
|
||||||
|
// fills whatever is left (`Min(0)`) — so both size themselves and
|
||||||
|
// the bars are as tall as the pane allows. With it off, the info
|
||||||
|
// block fills as before.
|
||||||
|
let info_lines = if self.track.is_some() { 4 } else { 3 };
|
||||||
|
let info_height = info_lines + 2; // + top and bottom border
|
||||||
|
let constraints = if self.spectrum_enabled {
|
||||||
|
vec![
|
||||||
|
Constraint::Length(info_height),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![Constraint::Min(3), Constraint::Length(1)]
|
||||||
|
};
|
||||||
let now_playing_layout = Layout::default()
|
let now_playing_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Max(8), Constraint::Max(1)])
|
.constraints(constraints)
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let media_info_text = if let Some(track) = &self.track {
|
let media_info_text = if let Some(track) = &self.track {
|
||||||
|
|
@ -89,8 +161,10 @@ impl NowPlaying {
|
||||||
None => "No album".to_string(),
|
None => "No album".to_string(),
|
||||||
};
|
};
|
||||||
let mods = format!(
|
let mods = format!(
|
||||||
"Shuffle: {}, Repeat {}",
|
"Shuffle: {}, Repeat: {}{}",
|
||||||
self.modifiers.shuffle, self.modifiers.repeat
|
self.modifiers.shuffle,
|
||||||
|
self.modifiers.repeat,
|
||||||
|
if self.muted { ", Muted" } else { "" },
|
||||||
);
|
);
|
||||||
vec![
|
vec![
|
||||||
Line::from(Span::raw(mods)),
|
Line::from(Span::raw(mods)),
|
||||||
|
|
@ -142,10 +216,16 @@ impl NowPlaying {
|
||||||
.constraints([Constraint::Min(10), Constraint::Max(completion_size)])
|
.constraints([Constraint::Min(10), Constraint::Max(completion_size)])
|
||||||
.split(now_playing_layout[1]);
|
.split(now_playing_layout[1]);
|
||||||
|
|
||||||
|
// Clamped: the player's position can overrun a stale or wrong
|
||||||
|
// duration (streams, hand-written track files), and
|
||||||
|
// `LineGauge` panics on ratios outside 0..=1.
|
||||||
let ratio = if duration.is_zero() {
|
let ratio = if duration.is_zero() {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
position.as_secs_f64().div(duration.as_secs_f64())
|
position
|
||||||
|
.as_secs_f64()
|
||||||
|
.div(duration.as_secs_f64())
|
||||||
|
.clamp(0.0, 1.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
let progress = LineGauge::default()
|
let progress = LineGauge::default()
|
||||||
|
|
@ -178,5 +258,131 @@ impl NowPlaying {
|
||||||
let time_p = Paragraph::new(Line::from(time_text));
|
let time_p = Paragraph::new(Line::from(time_text));
|
||||||
f.render_widget(time_p, elapsed_layout[1]);
|
f.render_widget(time_p, elapsed_layout[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The spectrum: full-height accent bars filling the region left
|
||||||
|
// below the progress. Columns stretch across the pane width
|
||||||
|
// regardless of the server's bin count.
|
||||||
|
if self.spectrum_enabled && !self.spectrum.is_empty() {
|
||||||
|
let area = now_playing_layout[2];
|
||||||
|
let (width, height) = (area.width as usize, area.height as usize);
|
||||||
|
if width > 0 && height > 0 {
|
||||||
|
let lines = spectrum_lines(&self.spectrum, width, height);
|
||||||
|
f.render_widget(Paragraph::new(lines), area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::{backend::TestBackend, Terminal};
|
||||||
|
|
||||||
|
/// A now-playing pane mid-track, built directly (no `update_track`,
|
||||||
|
/// which fires a desktop notification).
|
||||||
|
fn now_playing(position_ms: u32, duration_ms: u32) -> NowPlaying {
|
||||||
|
NowPlaying {
|
||||||
|
play_state: PlayState::Playing,
|
||||||
|
duration: Some(Duration::from_millis(duration_ms.into())),
|
||||||
|
modifiers: QueueModifiers::default(),
|
||||||
|
position: Some(Duration::from_millis(position_ms.into())),
|
||||||
|
track: Some(Track {
|
||||||
|
path: "/fs/radio.cbd-track.toml".to_string(),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: "title".to_string(),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
is_skipped: false,
|
||||||
|
}),
|
||||||
|
spectrum: Vec::new(),
|
||||||
|
spectrum_enabled: true,
|
||||||
|
muted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(pane: &NowPlaying) {
|
||||||
|
let backend = TestBackend::new(60, 12);
|
||||||
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
|
terminal
|
||||||
|
.draw(|f| pane.render(f, f.area()))
|
||||||
|
.expect("draw must not panic");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders and returns the buffer rows as strings.
|
||||||
|
fn rendered_rows(pane: &NowPlaying) -> Vec<String> {
|
||||||
|
let backend = TestBackend::new(60, 12);
|
||||||
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
|
terminal.draw(|f| pane.render(f, f.area())).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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spectrum_lines_fill_full_height_columns() {
|
||||||
|
// A full-level bin fills every row of its column with the full
|
||||||
|
// block; a zero bin leaves every row blank.
|
||||||
|
let lines = spectrum_lines(&[1.0, 0.0], 2, 4);
|
||||||
|
assert_eq!(lines.len(), 4, "one line per row");
|
||||||
|
let text: Vec<String> = lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
|
||||||
|
.collect();
|
||||||
|
// Column 0 (level 1.0) is full in every row; column 1 (0.0) empty.
|
||||||
|
assert!(text.iter().all(|row| row.starts_with('█')), "{text:?}");
|
||||||
|
assert!(text.iter().all(|row| row.ends_with(' ')), "{text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spectrum_lines_grow_from_the_bottom() {
|
||||||
|
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill.
|
||||||
|
let lines = spectrum_lines(&[0.5], 1, 4);
|
||||||
|
let col: Vec<char> = lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.spans[0].content.chars().next().unwrap())
|
||||||
|
.collect();
|
||||||
|
// Top rows empty, bottom rows full — bars rise from the floor.
|
||||||
|
assert_eq!(col[0], ' ', "top empty: {col:?}");
|
||||||
|
assert_eq!(col[3], '█', "bottom full: {col:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_spectrum_renders_block_glyphs_when_enabled() {
|
||||||
|
let mut pane = now_playing(10_000, 60_000);
|
||||||
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
|
let rows = rendered_rows(&pane);
|
||||||
|
// Full-level bars fill several rows with the full block.
|
||||||
|
let full_rows = rows.iter().filter(|r| r.contains('█')).count();
|
||||||
|
assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_spectrum_row_is_hidden_when_disabled() {
|
||||||
|
let mut pane = now_playing(10_000, 60_000);
|
||||||
|
pane.set_spectrum_enabled(false);
|
||||||
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
|
let rows = rendered_rows(&pane);
|
||||||
|
assert!(
|
||||||
|
!rows.iter().any(|r| r.contains('█')),
|
||||||
|
"disabled spectrum must not draw bars: {rows:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The position can overrun a stale or wrong duration (streams,
|
||||||
|
/// hand-written track files); the gauge must clamp instead of hitting
|
||||||
|
/// ratatui's `ratio should be between 0 and 1` panic.
|
||||||
|
#[test]
|
||||||
|
fn progress_gauge_survives_position_past_duration() {
|
||||||
|
render(&now_playing(90_000, 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_gauge_survives_a_zero_duration() {
|
||||||
|
render(&now_playing(5_000, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,24 @@ use ratatui::{
|
||||||
use crabidy_core::proto::crabidy::Queue as QueueData;
|
use crabidy_core::proto::crabidy::Queue as QueueData;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED,
|
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
||||||
|
COLOR_RED, COLOR_SECONDARY,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Queue {
|
pub struct Queue {
|
||||||
current_position: usize,
|
current_position: usize,
|
||||||
list: Vec<UiItem>,
|
list: Vec<UiItem>,
|
||||||
list_state: ListState,
|
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,
|
||||||
tx: Sender<MessageFromUi>,
|
tx: Sender<MessageFromUi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,9 +37,35 @@ impl Queue {
|
||||||
current_position: 0,
|
current_position: 0,
|
||||||
list: Vec::new(),
|
list: Vec::new(),
|
||||||
list_state: ListState::default(),
|
list_state: ListState::default(),
|
||||||
|
filter: Filter::default(),
|
||||||
|
resolving: false,
|
||||||
tx,
|
tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
pub fn play_next(&self) {
|
||||||
let _ = self.tx.send(MessageFromUi::NextTrack);
|
let _ = self.tx.send(MessageFromUi::NextTrack);
|
||||||
}
|
}
|
||||||
|
|
@ -36,15 +73,19 @@ impl Queue {
|
||||||
let _ = self.tx.send(MessageFromUi::PrevTrack);
|
let _ = self.tx.send(MessageFromUi::PrevTrack);
|
||||||
}
|
}
|
||||||
pub fn play_selected(&self) {
|
pub fn play_selected(&self) {
|
||||||
if let Some(pos) = self.selected() {
|
if let Some(pos) = self.selected_position() {
|
||||||
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn select_current(&mut self) {
|
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) {
|
pub fn remove_track(&mut self) {
|
||||||
if let Some(pos) = self.selected() {
|
if let Some(pos) = self.selected_position() {
|
||||||
// FIXME: mark multiple tracks on queue and remove them
|
// FIXME: mark multiple tracks on queue and remove them
|
||||||
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
|
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +95,7 @@ impl Queue {
|
||||||
}
|
}
|
||||||
pub fn update_queue(&mut self, queue: QueueData) {
|
pub fn update_queue(&mut self, queue: QueueData) {
|
||||||
self.current_position = queue.current_position as usize;
|
self.current_position = queue.current_position as usize;
|
||||||
|
self.resolving = queue.resolving;
|
||||||
self.list = queue
|
self.list = queue
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -63,33 +105,74 @@ impl Queue {
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: false,
|
marked: false,
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
|
is_editable: false,
|
||||||
|
is_deletable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
is_skipped: t.is_skipped,
|
||||||
})
|
})
|
||||||
.collect();
|
.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();
|
self.update_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||||
let queue_items: Vec<ListItem> = self
|
let selected = self.list_state.selected();
|
||||||
.list
|
// 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()
|
.iter()
|
||||||
|
.map(|&real| (real, &self.list[real]))
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, item)| {
|
.map(|(idx, (real, item))| {
|
||||||
let active = idx == self.current_position;
|
let active = real == self.current_position;
|
||||||
|
|
||||||
let title = if active {
|
let title = if active {
|
||||||
format!("> {}", item.title)
|
format!("> {}", item.title)
|
||||||
} else {
|
} else {
|
||||||
item.title.to_string()
|
item.title.to_string()
|
||||||
};
|
};
|
||||||
let style = if active {
|
let mut style = if active {
|
||||||
Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD)
|
Style::default().fg(COLOR_RED).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 {
|
} else {
|
||||||
Style::default()
|
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)
|
ListItem::new(Span::from(title)).style(style)
|
||||||
})
|
})
|
||||||
.collect();
|
.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)
|
let queue_list = List::new(queue_items)
|
||||||
.block(
|
.block(
|
||||||
|
|
@ -101,7 +184,10 @@ impl Queue {
|
||||||
} else {
|
} else {
|
||||||
COLOR_PRIMARY_DARK
|
COLOR_PRIMARY_DARK
|
||||||
}))
|
}))
|
||||||
.title("Queue"),
|
.title(match self.filter.query() {
|
||||||
|
Some(query) => format!("Queue — /{query}▏"),
|
||||||
|
None => "Queue".to_string(),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.highlight_style(Style::default().bg(if focused {
|
.highlight_style(Style::default().bg(if focused {
|
||||||
COLOR_PRIMARY
|
COLOR_PRIMARY
|
||||||
|
|
@ -115,7 +201,8 @@ impl Queue {
|
||||||
|
|
||||||
impl StatefulList for Queue {
|
impl StatefulList for Queue {
|
||||||
fn get_size(&self) -> usize {
|
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>) {
|
fn select(&mut self, idx: Option<usize>) {
|
||||||
|
|
@ -126,3 +213,207 @@ impl StatefulList for Queue {
|
||||||
self.list_state.selected()
|
self.list_state.selected()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
})
|
||||||
|
.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))
|
||||||
|
.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.
|
||||||
|
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))
|
||||||
|
.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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,23 @@ pub struct ServerConfig {
|
||||||
#[default("http://127.0.0.1:50051".to_string())]
|
#[default("http://127.0.0.1:50051".to_string())]
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub address: String,
|
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
//! 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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// FIXME: unwrap
|
||||||
|
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
||||||
|
|
||||||
|
let spectrum_enabled = config.server.spectrum;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
run_ui(ui_tx, ui_rx, spectrum_enabled);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn orchestrate(
|
||||||
|
config: &'static Config,
|
||||||
|
(tx, rx): (Sender<MessageToUi>, Receiver<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? {
|
||||||
|
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let init_data = rpc_client.init().await?;
|
||||||
|
info!("received initial state from server");
|
||||||
|
tx.send_async(MessageToUi::Init(init_data)).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
|
||||||
|
error!("request to server failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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::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?
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
tx.send_async(MessageToUi::Update(update)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("update stream broke, reconnecting: {err}");
|
||||||
|
rpc_client.reconnect_update_stream().await;
|
||||||
|
info!("update stream reconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled: bool) {
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(_) => { /* FIXME: implement */ }
|
||||||
|
StreamUpdate::CaptureProgress(progress) => {
|
||||||
|
app.captures.apply(progress);
|
||||||
|
}
|
||||||
|
StreamUpdate::Spectrum(frame) => {
|
||||||
|
app.now_playing.update_spectrum(frame.bins);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.confirm.is_some() {
|
||||||
|
app.handle_confirm_key(key);
|
||||||
|
} else if app.search.is_some() {
|
||||||
|
app.handle_search_key(key);
|
||||||
|
} else if app.input.is_some() {
|
||||||
|
app.handle_input_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,33 +1,11 @@
|
||||||
mod app;
|
//! The standalone TUI binary: file-based tracing (the terminal belongs
|
||||||
mod config;
|
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives
|
||||||
mod rpc;
|
//! in the library so the bundled `cbd` binary can host it too
|
||||||
|
//! (architecture/cbd-bundle.md D1).
|
||||||
|
|
||||||
use std::{
|
use std::sync::OnceLock;
|
||||||
error::Error,
|
|
||||||
io,
|
|
||||||
sync::OnceLock,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
|
use cbd_tui::config::Config;
|
||||||
|
|
||||||
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;
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||||
|
|
||||||
|
|
@ -67,316 +45,5 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
||||||
|
cbd_tui::run(config).await
|
||||||
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(
|
|
||||||
config: &'static Config,
|
|
||||||
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
info!(address = config.server.address, "connecting to server");
|
|
||||||
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
|
|
||||||
|
|
||||||
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
|
|
||||||
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let init_data = rpc_client.init().await?;
|
|
||||||
info!("received initial state from server");
|
|
||||||
tx.send_async(MessageToUi::Init(init_data)).await?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
|
|
||||||
error!("request to server failed: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(path) => {
|
|
||||||
if let Some(node) = rpc_client.get_library_node(&path).await? {
|
|
||||||
let _ = 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?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(err) => {
|
|
||||||
warn!("update stream broke, reconnecting: {err}");
|
|
||||||
rpc_client.reconnect_update_stream().await;
|
|
||||||
info!("update stream reconnected");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(_) => { /* FIXME: implement */ }
|
|
||||||
StreamUpdate::Volume(_) => { /* FIXME: implement */ }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
match (app.focus, key.modifiers, key.code) {
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('q')) => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Tab) => app.cycle_active(),
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char(' ')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::TogglePlay);
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('r')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::RestartTrack);
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::ChangeVolume(-0.1));
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::ChangeVolume(0.1));
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('m')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::ToggleMute);
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('z')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::ToggleShuffle);
|
|
||||||
}
|
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('x')) => {
|
|
||||||
let _ = 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')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::ClearQueue(true));
|
|
||||||
}
|
|
||||||
(UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('C')) => {
|
|
||||||
let _ = tx.send(MessageFromUi::ClearQueue(false));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if last_tick.elapsed() >= tick_rate {
|
|
||||||
last_tick = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore terminal
|
|
||||||
disable_raw_mode().unwrap();
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
terminal.show_cursor().unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||||
ClearQueueRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse,
|
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||||
InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||||
RemoveRequest, ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest,
|
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
||||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||||
|
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||||
|
ToggleShuffleRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
use tonic::{
|
use tonic::{
|
||||||
|
metadata::MetadataValue,
|
||||||
|
service::{interceptor::InterceptedService, Interceptor},
|
||||||
transport::{Channel, Endpoint},
|
transport::{Channel, Endpoint},
|
||||||
Request, Streaming,
|
Request, Status, Streaming,
|
||||||
};
|
};
|
||||||
|
|
||||||
// FIXME: use anyhow + thiserror
|
// FIXME: use anyhow + thiserror
|
||||||
|
|
@ -29,16 +34,78 @@ impl fmt::Display for RpcClientError {
|
||||||
|
|
||||||
impl Error 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 {
|
pub struct RpcClient {
|
||||||
library_node_cache: HashMap<String, LibraryNode>,
|
library_node_cache: HashMap<String, LibraryNode>,
|
||||||
client: CrabidyServiceClient<Channel>,
|
client: Client,
|
||||||
pub update_stream: Streaming<GetUpdateStreamResponse>,
|
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 —
|
||||||
|
/// captures finish (`W`), queues get saved (`w`), bookmarks appear,
|
||||||
|
/// files change on disk — so a cached listing turns freshly captured
|
||||||
|
/// content invisible until a restart. Their listings are cheap local
|
||||||
|
/// directory 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] = ["/captures", "/queues", "/bookmarks", "/fs"];
|
||||||
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
|
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcClient {
|
impl RpcClient {
|
||||||
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
|
pub async fn connect(
|
||||||
let endpoint = Endpoint::from_static(addr).connect_lazy();
|
server: &'static crate::config::ServerConfig,
|
||||||
let mut client = CrabidyServiceClient::new(endpoint);
|
) -> 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 update_stream = Self::get_update_stream(&mut client).await;
|
||||||
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
|
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
|
||||||
|
|
@ -50,9 +117,7 @@ impl RpcClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_update_stream(
|
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
|
||||||
client: &mut CrabidyServiceClient<Channel>,
|
|
||||||
) -> Streaming<GetUpdateStreamResponse> {
|
|
||||||
loop {
|
loop {
|
||||||
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
|
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
|
||||||
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
|
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
|
||||||
|
|
@ -77,7 +142,7 @@ impl RpcClient {
|
||||||
&mut self,
|
&mut self,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
|
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
|
||||||
if self.library_node_cache.contains_key(path) {
|
if is_cacheable(path) && self.library_node_cache.contains_key(path) {
|
||||||
return Ok(self.library_node_cache.get(path));
|
return Ok(self.library_node_cache.get(path));
|
||||||
}
|
}
|
||||||
let get_library_node_request = Request::new(GetLibraryNodeRequest {
|
let get_library_node_request = Request::new(GetLibraryNodeRequest {
|
||||||
|
|
@ -88,6 +153,8 @@ impl RpcClient {
|
||||||
.get_library_node(get_library_node_request)
|
.get_library_node(get_library_node_request)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(library_node) = response.into_inner().node {
|
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
|
self.library_node_cache
|
||||||
.insert(path.to_string(), library_node);
|
.insert(path.to_string(), library_node);
|
||||||
return Ok(self.library_node_cache.get(path));
|
return Ok(self.library_node_cache.get(path));
|
||||||
|
|
@ -95,6 +162,85 @@ impl RpcClient {
|
||||||
Err(Box::new(RpcClientError::NotFound))
|
Err(Box::new(RpcClientError::NotFound))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a child node under a creatable parent and returns it.
|
||||||
|
///
|
||||||
|
/// Cache contract: the created node is inserted into
|
||||||
|
/// `library_node_cache`, and the *parent's* cache entry is evicted —
|
||||||
|
/// its child listing just changed and would otherwise be served stale
|
||||||
|
/// when the user ascends back to it.
|
||||||
|
pub async fn create_library_node(
|
||||||
|
&mut self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||||
|
let request = Request::new(CreateLibraryNodeRequest {
|
||||||
|
parent_path: parent_path.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
});
|
||||||
|
let response = self.client.create_library_node(request).await?;
|
||||||
|
let Some(node) = response.into_inner().node else {
|
||||||
|
return Err(Box::new(RpcClientError::NotFound));
|
||||||
|
};
|
||||||
|
// The parent's child listing just changed; a cached copy would hide
|
||||||
|
// the new node when the user ascends back to it.
|
||||||
|
self.library_node_cache.remove(parent_path);
|
||||||
|
let path = node.path.clone();
|
||||||
|
self.library_node_cache.insert(path.clone(), node);
|
||||||
|
Ok(&self.library_node_cache[&path])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames an editable node and returns it at its (changed) path.
|
||||||
|
///
|
||||||
|
/// Cache contract: the old path's entry and the parent's entry are
|
||||||
|
/// evicted (the parent's child listing changed; the old path is dead),
|
||||||
|
/// and the renamed node is inserted under its new path.
|
||||||
|
pub async fn rename_library_node(
|
||||||
|
&mut self,
|
||||||
|
path: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||||
|
let request = Request::new(RenameLibraryNodeRequest {
|
||||||
|
path: path.to_string(),
|
||||||
|
new_title: new_title.to_string(),
|
||||||
|
});
|
||||||
|
let response = self.client.rename_library_node(request).await?;
|
||||||
|
let Some(node) = response.into_inner().node else {
|
||||||
|
return Err(Box::new(RpcClientError::NotFound));
|
||||||
|
};
|
||||||
|
// The old path is dead and the parent's child listing changed;
|
||||||
|
// cached copies would resurrect the old term.
|
||||||
|
self.library_node_cache.remove(path);
|
||||||
|
if let Some(parent) = crabidy_core::parent_path(path) {
|
||||||
|
self.library_node_cache.remove(parent);
|
||||||
|
}
|
||||||
|
let new_path = node.path.clone();
|
||||||
|
self.library_node_cache.insert(new_path.clone(), node);
|
||||||
|
Ok(&self.library_node_cache[&new_path])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a node and returns the refreshed parent listing.
|
||||||
|
///
|
||||||
|
/// Cache contract: the deleted path's entry and the parent's stale entry
|
||||||
|
/// are evicted, and the returned parent node is inserted fresh.
|
||||||
|
pub async fn delete_library_node(
|
||||||
|
&mut self,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||||
|
let request = Request::new(DeleteLibraryNodeRequest {
|
||||||
|
path: path.to_string(),
|
||||||
|
});
|
||||||
|
let response = self.client.delete_library_node(request).await?;
|
||||||
|
let Some(parent) = response.into_inner().parent else {
|
||||||
|
return Err(Box::new(RpcClientError::NotFound));
|
||||||
|
};
|
||||||
|
// Drop the deleted node and the stale parent listing; the response
|
||||||
|
// carries the fresh parent to cache instead.
|
||||||
|
self.library_node_cache.remove(path);
|
||||||
|
let parent_path = parent.path.clone();
|
||||||
|
self.library_node_cache.insert(parent_path.clone(), parent);
|
||||||
|
Ok(&self.library_node_cache[&parent_path])
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let append_request = Request::new(AppendRequest { paths });
|
let append_request = Request::new(AppendRequest { paths });
|
||||||
self.client.append(append_request).await?;
|
self.client.append(append_request).await?;
|
||||||
|
|
@ -134,6 +280,27 @@ impl RpcClient {
|
||||||
Ok(())
|
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>> {
|
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let replace_request = Request::new(ReplaceRequest { paths });
|
let replace_request = Request::new(ReplaceRequest { paths });
|
||||||
self.client.replace(replace_request).await?;
|
self.client.replace(replace_request).await?;
|
||||||
|
|
@ -196,3 +363,52 @@ impl RpcClient {
|
||||||
Ok(())
|
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 /captures hid new captures until a TUI restart).
|
||||||
|
for path in [
|
||||||
|
"/captures",
|
||||||
|
"/captures/faves",
|
||||||
|
"/queues",
|
||||||
|
"/queues/road trip",
|
||||||
|
"/bookmarks/b",
|
||||||
|
"/fs/music",
|
||||||
|
] {
|
||||||
|
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("/queuestore"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/dist
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
[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",
|
||||||
|
"Element",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"KeyboardEvent",
|
||||||
|
"Location",
|
||||||
|
"Performance",
|
||||||
|
"Storage",
|
||||||
|
"Window",
|
||||||
|
] }
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# 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. See
|
||||||
|
`architecture/web-client.md` for the design.
|
||||||
|
|
||||||
|
## 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 role
|
||||||
|
auth layer (`architecture/roles-auth.md`) 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, create/rename/delete nodes (`%`/`e`/`d`, with the capture-delete
|
||||||
|
`y/N` confirmation), bookmark and capture (`w`/`W`, with live progress
|
||||||
|
lines and skipped-track marking), the full queue and playback controls,
|
||||||
|
volume, shuffle/repeat, and a `?` help overlay listing the keys. Keys
|
||||||
|
mirror the TUI; every key also has a clickable control. 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`. Build the
|
||||||
|
server `--no-default-features` to drop the web client (and the
|
||||||
|
`tonic-web` layer) entirely.
|
||||||
|
|
||||||
|
## 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,391 @@
|
||||||
|
//! 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 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,
|
||||||
|
LibraryFirst,
|
||||||
|
LibraryLast,
|
||||||
|
LibraryNext,
|
||||||
|
LibraryPrev,
|
||||||
|
LibraryJumpDown,
|
||||||
|
LibraryJumpUp,
|
||||||
|
LibraryAscend,
|
||||||
|
LibraryDive,
|
||||||
|
LibraryToggleMark,
|
||||||
|
LibraryCaptureNode,
|
||||||
|
LibraryDownloadNode,
|
||||||
|
LibraryCreateNode,
|
||||||
|
LibraryEditNode,
|
||||||
|
LibraryDeleteNode,
|
||||||
|
LibraryQueueAppend,
|
||||||
|
LibraryQueueNext,
|
||||||
|
LibraryQueueReplace,
|
||||||
|
QueueFirst,
|
||||||
|
QueueLast,
|
||||||
|
QueueNext,
|
||||||
|
QueuePrev,
|
||||||
|
QueueJumpDown,
|
||||||
|
QueueJumpUp,
|
||||||
|
QueueSelectCurrent,
|
||||||
|
QueuePlaySelected,
|
||||||
|
QueueInsertHere,
|
||||||
|
QueueRemoveTrack,
|
||||||
|
QueueClearKeepCurrent,
|
||||||
|
QueueClearAll,
|
||||||
|
QueueSaveAs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: "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: "Ctrl-n",
|
||||||
|
description: "Next track",
|
||||||
|
},
|
||||||
|
HelpEntry {
|
||||||
|
scope: "Global",
|
||||||
|
key: "Ctrl-p",
|
||||||
|
description: "Previous 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: "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: "p",
|
||||||
|
description: "Insert library selection after 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: "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 {
|
||||||
|
"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),
|
||||||
|
"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),
|
||||||
|
"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),
|
||||||
|
"p" => Some(Action::QueueInsertHere),
|
||||||
|
"d" => Some(Action::QueueRemoveTrack),
|
||||||
|
"c" => Some(Action::QueueClearKeepCurrent),
|
||||||
|
"C" => Some(Action::QueueClearAll),
|
||||||
|
"w" => Some(Action::QueueSaveAs),
|
||||||
|
_ => 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,302 @@
|
||||||
|
//! 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, DeleteLibraryNodeRequest,
|
||||||
|
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||||
|
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest,
|
||||||
|
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||||
|
SetCurrentRequest, 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,517 @@
|
||||||
|
//! 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,
|
||||||
|
},
|
||||||
|
/// The capture-delete confirmation (architecture/capture-deletion.md).
|
||||||
|
ConfirmDelete { path: String, title: String },
|
||||||
|
/// Credentials form, shown on `UNAUTHENTICATED` responses.
|
||||||
|
Login,
|
||||||
|
/// The `?` key binding overlay.
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether deleting `path` needs the y/N confirmation — same rule as
|
||||||
|
/// the TUI: captures hold downloaded audio, everything else deletable
|
||||||
|
/// is cheap to recreate.
|
||||||
|
pub fn delete_needs_confirmation(path: &str) -> bool {
|
||||||
|
path == "/captures" || path.starts_with("/captures/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn is_cacheable(path: &str) -> bool {
|
||||||
|
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"];
|
||||||
|
!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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct QueueCursor {
|
||||||
|
pub selected: 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mm:ss` for progress and duration displays.
|
||||||
|
pub fn format_seconds(total: u32) -> String {
|
||||||
|
format!("{}:{:02}", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 capture_deletes_need_confirmation_cheap_deletes_do_not() {
|
||||||
|
assert!(delete_needs_confirmation("/captures/mix"));
|
||||||
|
assert!(delete_needs_confirmation("/captures/mix/a.cbd-track.toml"));
|
||||||
|
assert!(!delete_needs_confirmation("/queues/roadtrip"));
|
||||||
|
assert!(!delete_needs_confirmation("/tidal/search/abba"));
|
||||||
|
assert!(!delete_needs_confirmation("/capturesque"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mutable_roots_are_never_cacheable() {
|
||||||
|
for path in ["/captures", "/queues/x", "/bookmarks", "/fs/music"] {
|
||||||
|
assert!(!is_cacheable(path), "{path}");
|
||||||
|
}
|
||||||
|
for path in ["/tidal/playlists", "/youtube/search", "/capturesque"] {
|
||||||
|
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), "0:00");
|
||||||
|
assert_eq!(format_seconds(61), "1:01");
|
||||||
|
assert_eq!(format_seconds(3599), "59:59");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.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);
|
||||||
|
|
||||||
|
&.offline {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .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;
|
||||||
|
|
||||||
|
& .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;
|
||||||
|
|
||||||
|
& .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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger-dialog {
|
||||||
|
border-inline-start: 4px solid var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; Tab (or tapping a pane edge) switches — the
|
||||||
|
unfocused pane collapses to a slim strip acting as its tab. */
|
||||||
|
.panes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane:not(.focused) {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
max-block-size: 2.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-block-start: 1px solid var(--border);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
|
||||||
|
& .volume {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "cbd"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cbd-tui.workspace = true
|
||||||
|
crabidy-core.workspace = true
|
||||||
|
crabidy-server.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-appender.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
//! `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_tui::config::Config;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
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>> {
|
||||||
|
// 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();
|
||||||
|
// `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 config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd.toml"));
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = dirs::state_dir()
|
||||||
|
.or_else(dirs::cache_dir)
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("crabidy");
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,28 @@ edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
clap-serde-derive.workspace = true
|
flume.workspace = true
|
||||||
dirs.workspace = true
|
percent-encoding.workspace = true
|
||||||
prost.workspace = true
|
prost.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tonic.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
|
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]
|
[build-dependencies]
|
||||||
tonic-prost-build.workspace = true
|
tonic-prost-build.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tonic_prost_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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,32 @@ service CrabidyService {
|
||||||
|
|
||||||
// Library
|
// Library
|
||||||
rpc GetLibraryNode(GetLibraryNodeRequest) returns (GetLibraryNodeResponse);
|
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
|
// Queue
|
||||||
rpc Queue(QueueRequest) returns (QueueResponse);
|
rpc Queue(QueueRequest) returns (QueueResponse);
|
||||||
|
|
@ -51,6 +77,50 @@ message GetLibraryNodeResponse {
|
||||||
LibraryNode node = 1;
|
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
|
// Queue
|
||||||
message QueueRequest {
|
message QueueRequest {
|
||||||
repeated string paths = 1;
|
repeated string paths = 1;
|
||||||
|
|
@ -110,9 +180,43 @@ message GetUpdateStreamResponse {
|
||||||
float volume = 5;
|
float volume = 5;
|
||||||
bool mute = 6;
|
bool mute = 6;
|
||||||
TrackPosition position = 7;
|
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
|
// Playback
|
||||||
message TogglePlayRequest {}
|
message TogglePlayRequest {}
|
||||||
message TogglePlayResponse {}
|
message TogglePlayResponse {}
|
||||||
|
|
@ -142,6 +246,14 @@ message LibraryNodeChild {
|
||||||
string path = 1;
|
string path = 1;
|
||||||
string title = 2;
|
string title = 2;
|
||||||
bool is_queable = 3;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
message QueueModifiers {
|
message QueueModifiers {
|
||||||
|
|
@ -154,6 +266,10 @@ message Queue {
|
||||||
uint32 current_position = 2;
|
uint32 current_position = 2;
|
||||||
// Without album
|
// Without album
|
||||||
repeated Track tracks = 3;
|
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 {
|
message QueueTrack {
|
||||||
|
|
@ -187,6 +303,9 @@ message Track {
|
||||||
string title = 3;
|
string title = 3;
|
||||||
optional uint32 duration = 4;
|
optional uint32 duration = 4;
|
||||||
optional Album album = 5;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LibraryNode {
|
message LibraryNode {
|
||||||
|
|
@ -197,4 +316,12 @@ message LibraryNode {
|
||||||
optional string parent = 4;
|
optional string parent = 4;
|
||||||
repeated Track tracks = 5;
|
repeated Track tracks = 5;
|
||||||
bool is_queable = 6;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::{
|
use std::{
|
||||||
fs::{create_dir_all, read_to_string, File},
|
fs::{create_dir_all, read_to_string, File},
|
||||||
io::Write,
|
io::Write,
|
||||||
|
|
@ -5,6 +6,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use clap_serde_derive::{self, clap, serde, ClapSerde};
|
pub use clap_serde_derive::{self, clap, serde, ClapSerde};
|
||||||
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
||||||
|
|
||||||
|
|
@ -28,15 +30,110 @@ pub trait ProviderClient: std::fmt::Debug + Send + Sync {
|
||||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
||||||
fn get_lib_root(&self) -> LibraryNode;
|
fn get_lib_root(&self) -> LibraryNode;
|
||||||
async fn get_lib_node(&self, path: &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 {
|
pub enum ProviderError {
|
||||||
Config(String),
|
Config(String),
|
||||||
UnknownUser,
|
UnknownUser,
|
||||||
CouldNotLogin,
|
CouldNotLogin,
|
||||||
FetchError,
|
FetchError,
|
||||||
MalformedPath,
|
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,
|
InternalError,
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +175,32 @@ pub fn path_segments(path: &str) -> Vec<&str> {
|
||||||
path.split('/').filter(|s| !s.is_empty()).collect()
|
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 {
|
impl LibraryNode {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -87,16 +210,27 @@ impl LibraryNode {
|
||||||
parent: None,
|
parent: None,
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryNodeChild {
|
impl LibraryNodeChild {
|
||||||
|
/// A regular, non-creatable, immutable child. Creatable/editable/
|
||||||
|
/// deletable/downloadable children (e.g. the search node and its
|
||||||
|
/// terms, or Tidal's queueable subtrees) set the capability flags
|
||||||
|
/// explicitly via struct update.
|
||||||
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
title,
|
title,
|
||||||
is_queable,
|
is_queable,
|
||||||
|
is_creatable: false,
|
||||||
|
is_editable: false,
|
||||||
|
is_deletable: false,
|
||||||
|
is_downloadable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +239,7 @@ pub enum QueueError {
|
||||||
NotQueable,
|
NotQueable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn init_config<T>(config_file_name: &str) -> T
|
pub fn init_config<T>(config_file_name: &str) -> T
|
||||||
where
|
where
|
||||||
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,
|
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,
|
||||||
|
|
@ -163,4 +298,298 @@ mod tests {
|
||||||
vec!["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,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
is_queable,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,47 @@ edition.workspace = true
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# The embedded web client (architecture/web-client.md). On by default;
|
||||||
|
# disable for a headless-only binary without the bundle.
|
||||||
|
default = ["web-ui"]
|
||||||
|
web-ui = ["dep:tonic-web", "dep:include_dir"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
http.workspace = true
|
||||||
|
include_dir = { workspace = true, optional = true }
|
||||||
|
realfft.workspace = true
|
||||||
|
tonic-web = { workspace = true, optional = true }
|
||||||
|
tower.workspace = true
|
||||||
audio-player.workspace = true
|
audio-player.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
fsdy.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
tidaldy.workspace = true
|
tidaldy.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
toml.workspace = true
|
||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
tonic.workspace = true
|
tonic = { workspace = true, features = ["router", "transport", "codegen"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
ytdy.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
argon2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
http.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
//! 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() {
|
||||||
|
// 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,517 @@
|
||||||
|
//! 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" | "SetCurrent"
|
||||||
|
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
|
||||||
|
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" => 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 hash-password` helper.
|
||||||
|
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)>,
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
hashes,
|
||||||
|
verified: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any credential is configured (the auth on/off switch).
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
!self.hashes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the request's `authorization` header value to a role.
|
||||||
|
///
|
||||||
|
/// With auth disabled everyone is [`Role::Owner`]. Every failure —
|
||||||
|
/// missing header, wrong scheme, broken base64, unknown user,
|
||||||
|
/// wrong password — answers the same `UNAUTHENTICATED` so callers
|
||||||
|
/// cannot probe which part was wrong. Never panics on input.
|
||||||
|
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
|
||||||
|
if !self.enabled() {
|
||||||
|
return Ok(Role::Owner);
|
||||||
|
}
|
||||||
|
let denied = || Status::unauthenticated("credentials required");
|
||||||
|
let header = header.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",
|
||||||
|
"SetCurrent",
|
||||||
|
"ToggleShuffle",
|
||||||
|
"ToggleRepeat",
|
||||||
|
"TogglePlay",
|
||||||
|
"Stop",
|
||||||
|
"ChangeVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
"Next",
|
||||||
|
"Prev",
|
||||||
|
"RestartTrack",
|
||||||
|
];
|
||||||
|
let owner = [
|
||||||
|
"CaptureLibraryNode",
|
||||||
|
"SaveQueue",
|
||||||
|
"RenameLibraryNode",
|
||||||
|
"DeleteLibraryNode",
|
||||||
|
];
|
||||||
|
// The full service, from crabidy.proto — 24 methods. A new RPC
|
||||||
|
// must be added to exactly one list (and the layer keeps it
|
||||||
|
// owner-only until then).
|
||||||
|
assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24);
|
||||||
|
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.authenticate(None).expect("open"), Role::Owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
let cases: Vec<Option<String>> = vec![
|
||||||
|
None, // no header
|
||||||
|
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() {
|
||||||
|
let auth = Arc::new(authenticator());
|
||||||
|
let append = "http://s/crabidy.v1.CrabidyService/Append";
|
||||||
|
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
|
||||||
|
|
||||||
|
// No credentials: unauthenticated, handler never runs.
|
||||||
|
let (response, reached) = call_layer(auth.clone(), append, None);
|
||||||
|
assert!(!reached);
|
||||||
|
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
|
||||||
|
|
||||||
|
// Sufficient role: forwarded.
|
||||||
|
let (response, reached) = call_layer(auth.clone(), append, 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");
|
||||||
|
|
||||||
|
// 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,324 @@
|
||||||
|
//! Captured library subtrees ("bookmarks") on disk
|
||||||
|
//! (see `architecture/bookmarks.md`).
|
||||||
|
//!
|
||||||
|
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
|
||||||
|
//! mirrors the captured subtree: one order-prefixed folder per child node,
|
||||||
|
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
|
||||||
|
//! directory is mounted read-only into the library as `/bookmarks` by an
|
||||||
|
//! `fsdy` instance (with editable top-level folders) — this module is the
|
||||||
|
//! only writer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crabidy_core::ProviderClient;
|
||||||
|
|
||||||
|
/// The library mount point of the bookmarks directory.
|
||||||
|
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
|
||||||
|
|
||||||
|
/// The walk aborts beyond this many directories — a runaway provider tree
|
||||||
|
/// must not fill the disk.
|
||||||
|
pub const MAX_CAPTURE_DIRS: usize = crate::capture::BOOKMARK_CAPS.max_dirs;
|
||||||
|
|
||||||
|
/// The walk aborts beyond this many track files.
|
||||||
|
pub const MAX_CAPTURE_TRACKS: usize = crate::capture::BOOKMARK_CAPS.max_tracks;
|
||||||
|
|
||||||
|
/// The bookmarks directory: `bookmarks/` inside the crabidy config
|
||||||
|
/// directory. `None` when the platform has no config directory.
|
||||||
|
pub fn bookmarks_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use crate::capture::CaptureError;
|
||||||
|
|
||||||
|
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
|
||||||
|
/// built as a hidden temp sibling and swapped into place, so a crash never
|
||||||
|
/// leaves a half-written bookmark next to intact ones.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BookmarkStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BookmarkStore {
|
||||||
|
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||||
|
/// missing.
|
||||||
|
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
Ok(Self { dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store directory (what the `/bookmarks` provider instance
|
||||||
|
/// mounts).
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a bookmark request without writing anything: the name
|
||||||
|
/// must be a legal folder name. The accept-then-stream RPC replies
|
||||||
|
/// after this and runs [`Self::capture`] detached
|
||||||
|
/// (architecture/incremental-captures.md D4).
|
||||||
|
pub fn validate(&self, name: &str) -> Result<(), CaptureError> {
|
||||||
|
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the subtree at `source_path` as the bookmark `name`,
|
||||||
|
/// overwriting an existing bookmark of that name. Reports through
|
||||||
|
/// `progress` (non-terminal events only; the caller sends the
|
||||||
|
/// terminal one).
|
||||||
|
///
|
||||||
|
/// Walks `client` (the orchestrator, so any provider is reachable)
|
||||||
|
/// iteratively in pre-order: every child node becomes an
|
||||||
|
/// order-prefixed folder, every track an order-prefixed link file
|
||||||
|
/// ([`fsdy::TrackFile::from_track`]; a skipped source track writes a
|
||||||
|
/// skipped toml). A `source_path` that is itself a track captures as
|
||||||
|
/// a folder with one file. Aborts with [`CaptureError::TooLarge`]
|
||||||
|
/// beyond [`MAX_CAPTURE_DIRS`] / [`MAX_CAPTURE_TRACKS`]; an
|
||||||
|
/// unreadable source is [`CaptureError::BadSource`]. Never panics on
|
||||||
|
/// provider contents.
|
||||||
|
pub async fn capture<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
progress: &crate::capture::Progress,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
self.capture_with_caps(
|
||||||
|
client,
|
||||||
|
source_path,
|
||||||
|
name,
|
||||||
|
MAX_CAPTURE_DIRS,
|
||||||
|
MAX_CAPTURE_TRACKS,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||||
|
async fn capture_with_caps<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
max_dirs: usize,
|
||||||
|
max_tracks: usize,
|
||||||
|
progress: &crate::capture::Progress,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
let caps = crate::capture::Caps {
|
||||||
|
max_dirs,
|
||||||
|
max_tracks,
|
||||||
|
..crate::capture::BOOKMARK_CAPS
|
||||||
|
};
|
||||||
|
crate::capture::capture_into(
|
||||||
|
&self.dir,
|
||||||
|
client,
|
||||||
|
source_path,
|
||||||
|
name,
|
||||||
|
caps,
|
||||||
|
&crate::capture::Sink::Link,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A silent progress reporter; bookmark tests assert on disk state.
|
||||||
|
fn silent() -> crate::capture::Progress {
|
||||||
|
crate::capture::Progress::silent("test", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real fsdy instance as the capture source: an artist with two
|
||||||
|
/// albums holding url tracks, plus one link track pointing at Tidal.
|
||||||
|
async fn source() -> (fsdy::Client, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("source tempdir");
|
||||||
|
let al1 = dir.path().join("artist/Album One");
|
||||||
|
let al2 = dir.path().join("artist/Album Two");
|
||||||
|
fs::create_dir_all(&al1).expect("mkdir");
|
||||||
|
fs::create_dir_all(&al2).expect("mkdir");
|
||||||
|
let url =
|
||||||
|
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
|
||||||
|
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
|
||||||
|
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
|
||||||
|
fs::write(
|
||||||
|
al2.join("01 linked.cbd-track.toml"),
|
||||||
|
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
|
||||||
|
(client, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store() -> (BookmarkStore, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("store tempdir");
|
||||||
|
let store = BookmarkStore::open(dir.path().join("bookmarks"))
|
||||||
|
.await
|
||||||
|
.expect("open creates the directory");
|
||||||
|
(store, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = fs::read_dir(dir)
|
||||||
|
.expect("dir")
|
||||||
|
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| !n.starts_with('.'))
|
||||||
|
.collect();
|
||||||
|
names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_mirrors_a_subtree_with_order_prefixes() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
|
||||||
|
let root = store.dir().join("faves");
|
||||||
|
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
|
||||||
|
let album1 = visible(&root.join("0001 Album One"));
|
||||||
|
assert_eq!(
|
||||||
|
album1,
|
||||||
|
vec![
|
||||||
|
"0001 one.cbd-track.toml".to_string(),
|
||||||
|
"0002 two.cbd-track.toml".into()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Entries are link files; the url track links back to its /fs path,
|
||||||
|
// the link track re-links to its original target (no chains).
|
||||||
|
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
|
||||||
|
.expect("read entry");
|
||||||
|
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
|
||||||
|
assert_eq!(
|
||||||
|
one.to_track("/bookmarks/irrelevant").path,
|
||||||
|
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
|
||||||
|
);
|
||||||
|
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
|
||||||
|
.expect("read entry");
|
||||||
|
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
|
||||||
|
assert_eq!(
|
||||||
|
linked.to_track("/bookmarks/irrelevant").path,
|
||||||
|
"/tidal/artists/1/2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn captured_trees_replay_through_a_bookmarks_instance() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
|
||||||
|
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||||
|
.expect("bookmarks instance");
|
||||||
|
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||||
|
bookmarks
|
||||||
|
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
|
||||||
|
.await
|
||||||
|
.expect("resolve");
|
||||||
|
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
|
||||||
|
// Pre-order over the mirrored structure == source listing order.
|
||||||
|
assert_eq!(
|
||||||
|
titles,
|
||||||
|
vec!["one".to_string(), "two".into(), "linked".into()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capturing_a_single_track_writes_one_file() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(
|
||||||
|
&client,
|
||||||
|
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
|
||||||
|
"just one",
|
||||||
|
&silent(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capture track");
|
||||||
|
let entries = visible(&store.dir().join("just one"));
|
||||||
|
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_validates_names_and_overwrites() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
for bad in ["", " ", "a/b", ".hidden"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.capture(&client, "/fs/artist", bad, &silent()).await,
|
||||||
|
Err(CaptureError::InvalidName(_))
|
||||||
|
),
|
||||||
|
"name {bad:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("first capture");
|
||||||
|
store
|
||||||
|
.capture(&client, "/fs/artist/Album%20Two", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("overwrite");
|
||||||
|
// The overwrite fully replaces the older, larger capture.
|
||||||
|
assert_eq!(
|
||||||
|
visible(&store.dir().join("faves")),
|
||||||
|
vec!["0001 linked.cbd-track.toml"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_rejects_an_unreadable_source() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
assert!(matches!(
|
||||||
|
store.capture(&client, "/fs/nope", "x", &silent()).await,
|
||||||
|
Err(CaptureError::BadSource(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_aborts_over_the_caps_and_cleans_up() {
|
||||||
|
let (client, _src) = source().await;
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
|
||||||
|
let err = store
|
||||||
|
.capture_with_caps(
|
||||||
|
&client,
|
||||||
|
"/fs/artist",
|
||||||
|
"big",
|
||||||
|
2,
|
||||||
|
MAX_CAPTURE_TRACKS,
|
||||||
|
&silent(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("over the dir cap");
|
||||||
|
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||||
|
// ... same for the track cap.
|
||||||
|
let err = store
|
||||||
|
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1, &silent())
|
||||||
|
.await
|
||||||
|
.expect_err("over the track cap");
|
||||||
|
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||||
|
// Nothing half-written survives, not even hidden temp folders.
|
||||||
|
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
|
||||||
|
assert_eq!(leftovers, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,828 @@
|
||||||
|
//! The shared subtree-capture walk
|
||||||
|
//! (see `architecture/captures.md` D2 and
|
||||||
|
//! `architecture/incremental-captures.md` D2).
|
||||||
|
//!
|
||||||
|
//! Both bookmark captures (`w`, link files) and download captures (`W`,
|
||||||
|
//! audio files next to their tomls) mirror a library subtree into a folder.
|
||||||
|
//! The walk runs in two phases: **enumerate** first (every directory and
|
||||||
|
//! track, enforcing the size caps — this makes the total known before the
|
||||||
|
//! first download), then **fetch** track by track, reporting [`Progress`]
|
||||||
|
//! after each one.
|
||||||
|
//!
|
||||||
|
//! The per-track [`Sink`] decides the write mode: bookmarks build the whole
|
||||||
|
//! capture in a hidden tmp sibling and swap it into place (all-or-nothing,
|
||||||
|
//! overwrite = refresh), download captures write **incrementally** into the
|
||||||
|
//! final folder — entries that are already satisfied are reused, tracks
|
||||||
|
//! whose source cannot be captured are recorded as *skipped* tomls, and a
|
||||||
|
//! real download failure aborts the run but keeps everything written so
|
||||||
|
//! far, so re-running the same name resumes where it stopped.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
||||||
|
use crabidy_core::ProviderClient;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// [`Sink::Link`]). Reused entries of a resumed capture 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("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),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
||||||
|
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).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to each track the walk visits.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Sink {
|
||||||
|
/// Write an order-prefixed link file ([`fsdy::TrackFile::from_track`])
|
||||||
|
/// — the bookmark behavior: tmp-and-swap, all-or-nothing.
|
||||||
|
Link,
|
||||||
|
/// Download the track's audio next to an order-prefixed toml that
|
||||||
|
/// points at it — the captures behavior: incremental into the final
|
||||||
|
/// folder, resumable by name.
|
||||||
|
Download(Downloader),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one visited track settled during the fetch phase.
|
||||||
|
enum TrackOutcome {
|
||||||
|
/// Playable data is in place (downloaded now, or already there).
|
||||||
|
Captured,
|
||||||
|
/// The source cannot be captured; a skipped toml records the gap.
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One track discovered by the enumeration phase: what to fetch, where to
|
||||||
|
/// put it, and its listing position (the order prefix).
|
||||||
|
struct TrackEntry {
|
||||||
|
track: Track,
|
||||||
|
dir: PathBuf,
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settles one track: audio file first, then the toml pointing at
|
||||||
|
/// it — a toml never exists without its audio. The whole operation is
|
||||||
|
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`].
|
||||||
|
///
|
||||||
|
/// Error messages carry the track's library path, never the stream
|
||||||
|
/// URL (it may embed a token) — reqwest errors are stripped with
|
||||||
|
/// [`reqwest::Error::without_url`].
|
||||||
|
async fn download_track<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
track: &Track,
|
||||||
|
dir: &Path,
|
||||||
|
index: usize,
|
||||||
|
bytes_left: &mut u64,
|
||||||
|
) -> Result<TrackOutcome, CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
let fetched = tokio::time::timeout(
|
||||||
|
DOWNLOAD_TRACK_TIMEOUT,
|
||||||
|
self.fetch_track(client, track, dir, index, bytes_left),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match fetched {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(CaptureError::Download(format!(
|
||||||
|
"{}: timed out after {}s",
|
||||||
|
track.path,
|
||||||
|
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unbounded body of [`Self::download_track`]: resolve the stream
|
||||||
|
/// URL, stream the response to disk against the byte budget, then
|
||||||
|
/// write the toml.
|
||||||
|
///
|
||||||
|
/// A track whose source cannot be captured — it is itself skipped, its
|
||||||
|
/// stream fails to resolve, or it resolves to something other than an
|
||||||
|
/// http(s) URL (e.g. a local file playable) — is recorded as a
|
||||||
|
/// **skipped toml** instead of aborting the capture
|
||||||
|
/// (architecture/incremental-captures.md D2): queue and bookmark
|
||||||
|
/// captures mix providers, and one local track must not kill the rest.
|
||||||
|
/// Actual download failures stay fatal for the run.
|
||||||
|
async fn fetch_track<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
track: &Track,
|
||||||
|
dir: &Path,
|
||||||
|
index: usize,
|
||||||
|
bytes_left: &mut u64,
|
||||||
|
) -> Result<TrackOutcome, CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
let track_path = track.path.as_str();
|
||||||
|
if track.is_skipped {
|
||||||
|
warn!(path = track_path, "recording an already-skipped track");
|
||||||
|
return write_skipped(track, dir, index).await;
|
||||||
|
}
|
||||||
|
let urls = match client.get_urls_for_track(track_path).await {
|
||||||
|
Ok(urls) => urls,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
path = track_path,
|
||||||
|
"recording uncapturable track as skipped: {err}"
|
||||||
|
);
|
||||||
|
return write_skipped(track, dir, index).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(url) = urls.first() else {
|
||||||
|
warn!(
|
||||||
|
path = track_path,
|
||||||
|
"recording track without a stream url as skipped"
|
||||||
|
);
|
||||||
|
return write_skipped(track, dir, index).await;
|
||||||
|
};
|
||||||
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
|
// A local file playable (an fs track, or a re-captured
|
||||||
|
// capture): the audio is already on disk, so copy it into the
|
||||||
|
// capture instead of skipping it.
|
||||||
|
return self.copy_local(track, url, dir, index, bytes_left).await;
|
||||||
|
}
|
||||||
|
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 audio_name = audio_file_name(index, &track.title, &ext);
|
||||||
|
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).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?;
|
||||||
|
drop(audio);
|
||||||
|
|
||||||
|
let text =
|
||||||
|
fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name)).to_toml()?;
|
||||||
|
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||||
|
Ok(TrackOutcome::Captured)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies an already-local audio file (an fs playable, or a track
|
||||||
|
/// from an existing capture) into the capture folder next to its
|
||||||
|
/// toml. A source that is missing or not a regular file is recorded
|
||||||
|
/// as skipped rather than aborting the run; the copy counts against
|
||||||
|
/// the same byte budget as a download so a capture cannot run away.
|
||||||
|
async fn copy_local(
|
||||||
|
&self,
|
||||||
|
track: &Track,
|
||||||
|
source: &str,
|
||||||
|
dir: &Path,
|
||||||
|
index: usize,
|
||||||
|
bytes_left: &mut u64,
|
||||||
|
) -> Result<TrackOutcome, CaptureError> {
|
||||||
|
let track_path = track.path.as_str();
|
||||||
|
let source_path = Path::new(source);
|
||||||
|
match tokio::fs::metadata(source_path).await {
|
||||||
|
Ok(meta) if meta.is_file() => {
|
||||||
|
let len = meta.len();
|
||||||
|
if len > *bytes_left {
|
||||||
|
return Err(CaptureError::TooLarge("download budget exhausted"));
|
||||||
|
}
|
||||||
|
let ext = source_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("bin");
|
||||||
|
let audio_name = audio_file_name(index, &track.title, ext);
|
||||||
|
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
|
||||||
|
*bytes_left -= len;
|
||||||
|
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
|
||||||
|
.to_toml()?;
|
||||||
|
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
|
||||||
|
.await?;
|
||||||
|
Ok(TrackOutcome::Captured)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!(
|
||||||
|
path = track_path,
|
||||||
|
"local playable is not a readable file; recording as skipped"
|
||||||
|
);
|
||||||
|
write_skipped(track, dir, index).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the skipped toml for `track` at listing position `index`,
|
||||||
|
/// overwriting whatever was there.
|
||||||
|
async fn write_skipped(
|
||||||
|
track: &Track,
|
||||||
|
dir: &Path,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<TrackOutcome, CaptureError> {
|
||||||
|
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
|
||||||
|
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||||
|
Ok(TrackOutcome::Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the subtree at `source_path` as `dir/<name>/`.
|
||||||
|
///
|
||||||
|
/// Validates `name` ([`fsdy::validate_folder_name`], nothing reserved),
|
||||||
|
/// then enumerates the subtree (caps enforced, total reported) and fetches
|
||||||
|
/// track by track. [`Sink::Link`] builds the whole capture in a hidden
|
||||||
|
/// `.tmp-<name>` sibling and swaps it into place, removing the temp folder
|
||||||
|
/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes
|
||||||
|
/// incrementally into `dir/<name>` itself: satisfied entries are reused,
|
||||||
|
/// uncapturable tracks become skipped tomls, and failures keep everything
|
||||||
|
/// already written (re-run the same name to resume).
|
||||||
|
pub async fn capture_into<C>(
|
||||||
|
dir: &Path,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
caps: Caps,
|
||||||
|
sink: &Sink,
|
||||||
|
progress: &Progress,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||||
|
match sink {
|
||||||
|
Sink::Link => {
|
||||||
|
let tmp = dir.join(format!(".tmp-{name}"));
|
||||||
|
let written = write_links(client, source_path, &tmp, caps, progress).await;
|
||||||
|
if let Err(err) = written {
|
||||||
|
// Every failure path removes the temp folder: nothing
|
||||||
|
// half-written survives, not even hidden.
|
||||||
|
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
let target = dir.join(name);
|
||||||
|
if tokio::fs::try_exists(&target).await? {
|
||||||
|
tokio::fs::remove_dir_all(&target).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&tmp, &target).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Sink::Download(downloader) => {
|
||||||
|
let target = dir.join(name);
|
||||||
|
tokio::fs::create_dir_all(&target).await?;
|
||||||
|
let entries = enumerate(client, source_path, &target, caps).await?;
|
||||||
|
progress.set_total(entries.len());
|
||||||
|
let mut bytes_left = caps.max_bytes;
|
||||||
|
for entry in &entries {
|
||||||
|
if existing_is_satisfied(&entry.dir, entry.index, &entry.track.title).await {
|
||||||
|
progress.track_done();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let outcome = downloader
|
||||||
|
.download_track(
|
||||||
|
client,
|
||||||
|
&entry.track,
|
||||||
|
&entry.dir,
|
||||||
|
entry.index,
|
||||||
|
&mut bytes_left,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match outcome {
|
||||||
|
TrackOutcome::Captured => progress.track_done(),
|
||||||
|
TrackOutcome::Skipped => progress.track_skipped(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bookmark walk body: fresh tmp folder, enumerate, one link file per
|
||||||
|
/// track ([`fsdy::TrackFile::from_track`] — skipped source tracks write
|
||||||
|
/// skipped tomls). The caller owns cleanup on error.
|
||||||
|
async fn write_links<C>(
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
tmp: &Path,
|
||||||
|
caps: Caps,
|
||||||
|
progress: &Progress,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
// A leftover temp folder from a crashed or racing capture is stale.
|
||||||
|
if tokio::fs::try_exists(tmp).await? {
|
||||||
|
tokio::fs::remove_dir_all(tmp).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(tmp).await?;
|
||||||
|
let entries = enumerate(client, source_path, tmp, caps).await?;
|
||||||
|
progress.set_total(entries.len());
|
||||||
|
for entry in &entries {
|
||||||
|
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
|
||||||
|
let file = entry
|
||||||
|
.dir
|
||||||
|
.join(fsdy::track_file_name(entry.index, &entry.track.title));
|
||||||
|
tokio::fs::write(file, text).await?;
|
||||||
|
progress.track_done();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: 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); existing directories are reused
|
||||||
|
/// (`create_dir_all`), which is what makes download captures resumable. A
|
||||||
|
/// `source_path` that is a track enumerates as a single entry. Enforces
|
||||||
|
/// `max_dirs`/`max_tracks`.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the entry for track `title` at listing position `index` in
|
||||||
|
/// `dir` already has playable data: a parseable toml whose playable is not
|
||||||
|
/// skipped, and — for a `file` playable — whose audio file exists.
|
||||||
|
/// Anything else (missing, broken, skipped, audio gone) is re-captured.
|
||||||
|
async fn existing_is_satisfied(dir: &Path, index: usize, title: &str) -> bool {
|
||||||
|
let toml_path = dir.join(fsdy::track_file_name(index, title));
|
||||||
|
let Ok(text) = tokio::fs::read_to_string(&toml_path).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(file) = fsdy::TrackFile::parse(&text) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match file.playable() {
|
||||||
|
Ok(fsdy::Playable::File(target)) => {
|
||||||
|
let absolute = if target.is_absolute() {
|
||||||
|
target
|
||||||
|
} else {
|
||||||
|
dir.join(target)
|
||||||
|
};
|
||||||
|
tokio::fs::try_exists(absolute).await.unwrap_or(false)
|
||||||
|
}
|
||||||
|
Ok(fsdy::Playable::Skipped) => false,
|
||||||
|
// A url/link playable was not written by this store, but whoever
|
||||||
|
// put it there gave the entry playable data — keep it.
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio file name for track `index` titled `title` with `ext`, next
|
||||||
|
/// to its toml: `NNNN <title>.<ext>` through the same sanitizer as the
|
||||||
|
/// toml name, so the pair sorts together — and what the toml's relative
|
||||||
|
/// `file` playable points at.
|
||||||
|
fn audio_file_name(index: usize, title: &str, ext: &str) -> String {
|
||||||
|
format!("{}.{ext}", fsdy::dir_name(index, title))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 audio_files_pair_with_their_toml_names() {
|
||||||
|
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
|
||||||
|
// audio file sorts right next to its toml.
|
||||||
|
assert_eq!(
|
||||||
|
audio_file_name(0, "We Will Rock You", "flac"),
|
||||||
|
"0001 We Will Rock You.flac"
|
||||||
|
);
|
||||||
|
assert_eq!(audio_file_name(11, "a/b", "mp3"), "0012 a_b.mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,783 @@
|
||||||
|
//! Downloaded library subtrees ("captures") on disk
|
||||||
|
//! (see `architecture/captures.md` and
|
||||||
|
//! `architecture/incremental-captures.md`).
|
||||||
|
//!
|
||||||
|
//! Every capture is a folder under `<config>/crabidy/captures/` that
|
||||||
|
//! mirrors the captured subtree like a bookmark, except each track's audio
|
||||||
|
//! is **downloaded** next to its order-prefixed `*.cbd-track.toml`, and the
|
||||||
|
//! toml's playable is a relative `file` pointing at it — replaying a
|
||||||
|
//! capture needs no provider round trip. Tracks whose source cannot be
|
||||||
|
//! captured are recorded as *skipped* tomls. The same directory is mounted
|
||||||
|
//! read-only into the library as `/captures` by an `fsdy` instance (with
|
||||||
|
//! editable top-level folders); this module is the only writer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crabidy_core::ProviderClient;
|
||||||
|
|
||||||
|
use crate::capture::{Caps, CaptureError, Progress};
|
||||||
|
|
||||||
|
/// The library mount point of the captures directory.
|
||||||
|
pub const CAPTURES_PROVIDER_ROOT: &str = "/captures";
|
||||||
|
|
||||||
|
/// The captures directory: `captures/` inside the crabidy config
|
||||||
|
/// directory. `None` when the platform has no config directory.
|
||||||
|
pub fn captures_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|d| d.join("crabidy").join("captures"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes download captures. All audio is fetched through one shared HTTP
|
||||||
|
/// client. Captures are **incremental**: the walk writes into the final
|
||||||
|
/// folder, reuses entries that already have their audio, and keeps
|
||||||
|
/// everything written so far when a download fails — capturing the same
|
||||||
|
/// name again resumes (and completes previously skipped entries where the
|
||||||
|
/// source became capturable).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CaptureStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
sink: crate::capture::Sink,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureStore {
|
||||||
|
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||||
|
/// missing, and builds the shared HTTP client.
|
||||||
|
pub async fn open(dir: PathBuf) -> Result<Self, CaptureError> {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let downloader = crate::capture::Downloader::new()
|
||||||
|
.map_err(|err| CaptureError::Download(format!("cannot build http client: {err}")))?;
|
||||||
|
Ok(Self {
|
||||||
|
dir,
|
||||||
|
sink: crate::capture::Sink::Download(downloader),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store directory (what the `/captures` provider instance
|
||||||
|
/// mounts).
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a capture request without writing anything: the name must
|
||||||
|
/// be a legal folder name and the capture **root** must opt in
|
||||||
|
/// (`architecture/captures.md` D4 — a directory source must report
|
||||||
|
/// `is_downloadable`, a track source's parent node must). The
|
||||||
|
/// accept-then-stream RPC replies after this and runs
|
||||||
|
/// [`Self::capture`] detached (architecture/incremental-captures.md
|
||||||
|
/// D4).
|
||||||
|
pub async fn validate<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||||
|
source_allows_download(client, source_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the subtree at `source_path` as the capture `name`,
|
||||||
|
/// downloading every track's audio — **incrementally**: an existing
|
||||||
|
/// capture of that name is resumed, not overwritten (satisfied entries
|
||||||
|
/// are reused, skipped and broken ones re-attempted). Reports through
|
||||||
|
/// `progress` (non-terminal events only; the caller sends the terminal
|
||||||
|
/// one).
|
||||||
|
///
|
||||||
|
/// The source must pass [`Self::validate`]. The walk, caps
|
||||||
|
/// ([`crate::capture::DOWNLOAD_CAPS`]), reuse rule, and skipped tomls
|
||||||
|
/// are [`crate::capture::capture_into`]'s.
|
||||||
|
pub async fn capture<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
progress: &Progress,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
self.capture_with_caps(
|
||||||
|
client,
|
||||||
|
source_path,
|
||||||
|
name,
|
||||||
|
crate::capture::DOWNLOAD_CAPS,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||||
|
async fn capture_with_caps<C>(
|
||||||
|
&self,
|
||||||
|
client: &C,
|
||||||
|
source_path: &str,
|
||||||
|
name: &str,
|
||||||
|
caps: Caps,
|
||||||
|
progress: &Progress,
|
||||||
|
) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
source_allows_download(client, source_path).await?;
|
||||||
|
crate::capture::capture_into(
|
||||||
|
&self.dir,
|
||||||
|
client,
|
||||||
|
source_path,
|
||||||
|
name,
|
||||||
|
caps,
|
||||||
|
&self.sink,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that the capture root allows downloads: the node at
|
||||||
|
/// `source_path` (or, for a track path, its parent node) must set
|
||||||
|
/// `is_downloadable`. An unreadable root is [`CaptureError::BadSource`].
|
||||||
|
async fn source_allows_download<C>(client: &C, source_path: &str) -> Result<(), CaptureError>
|
||||||
|
where
|
||||||
|
C: ProviderClient + Sync,
|
||||||
|
{
|
||||||
|
let node_path = if client.is_track_path(source_path) {
|
||||||
|
crabidy_core::parent_path(source_path).unwrap_or(source_path)
|
||||||
|
} else {
|
||||||
|
source_path
|
||||||
|
};
|
||||||
|
let node = client
|
||||||
|
.get_lib_node(node_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
|
||||||
|
if node.is_downloadable {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CaptureError::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::capture::{Caps, DOWNLOAD_CAPS};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
||||||
|
use crabidy_core::ProviderError;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// Serves every request with one fixed response and returns a URL for
|
||||||
|
/// it. Minimal HTTP/1.1 on a loopback socket — enough for reqwest.
|
||||||
|
async fn serve(status: &'static str, content_type: &'static str, body: Vec<u8>) -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind test server");
|
||||||
|
let addr = listener.local_addr().expect("test server addr");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((mut sock, _)) = listener.accept().await {
|
||||||
|
let body = body.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Read until the header terminator; the request itself
|
||||||
|
// is irrelevant.
|
||||||
|
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 head = format!(
|
||||||
|
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = sock.write_all(head.as_bytes()).await;
|
||||||
|
let _ = sock.write_all(&body).await;
|
||||||
|
let _ = sock.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{addr}/stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`serve`], but the server enforces bounded ranges the way
|
||||||
|
/// googlevideo does: ranged requests up to `max_window` bytes get
|
||||||
|
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
|
||||||
|
/// oversized) gets `403`.
|
||||||
|
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind test server");
|
||||||
|
let addr = listener.local_addr().expect("test server 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: u64 = end.parse().ok()?;
|
||||||
|
Some((start, end))
|
||||||
|
});
|
||||||
|
let total = body.len() as u64;
|
||||||
|
let response = match range {
|
||||||
|
Some((start, 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-Type: {content_type}\r\n\
|
||||||
|
Content-Range: bytes {start}-{end}/{total}\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
slice.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
head.extend_from_slice(slice);
|
||||||
|
head
|
||||||
|
}
|
||||||
|
Some((start, _)) if start >= total => format!(
|
||||||
|
"HTTP/1.1 416 Range Not Satisfiable\r\n\
|
||||||
|
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
|
||||||
|
Connection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
.to_vec(),
|
||||||
|
};
|
||||||
|
let _ = sock.write_all(&response).await;
|
||||||
|
let _ = sock.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{addr}/stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
|
||||||
|
/// stream URLs are the test server's, and a node-level download
|
||||||
|
/// blessing toggle.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MockProvider {
|
||||||
|
urls: HashMap<String, String>,
|
||||||
|
downloadable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockProvider {
|
||||||
|
fn new(urls: &[(&str, &str)], downloadable: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
urls: urls
|
||||||
|
.iter()
|
||||||
|
.map(|(p, u)| (p.to_string(), u.to_string()))
|
||||||
|
.collect(),
|
||||||
|
downloadable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(&self, path: &str) -> Track {
|
||||||
|
let title = match path {
|
||||||
|
"/mock/a/1" => "one",
|
||||||
|
"/mock/a/2" => "two",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
Track {
|
||||||
|
path: path.to_string(),
|
||||||
|
artist: "mock".to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
duration: Some(10),
|
||||||
|
album: None,
|
||||||
|
is_skipped: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderClient for MockProvider {
|
||||||
|
async fn init(_s: &str) -> Result<Self, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
fn settings(&self) -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
|
path.starts_with("/mock/a/") && path.len() > "/mock/a/".len()
|
||||||
|
}
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
|
self.urls
|
||||||
|
.get(track_path)
|
||||||
|
.map(|u| vec![u.clone()])
|
||||||
|
.ok_or(ProviderError::FetchError)
|
||||||
|
}
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
|
if !self.is_track_path(track_path) {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
Ok(self.track(track_path))
|
||||||
|
}
|
||||||
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
|
LibraryNode::new()
|
||||||
|
}
|
||||||
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let mut node = LibraryNode::new();
|
||||||
|
node.path = path.to_string();
|
||||||
|
node.is_downloadable = self.downloadable;
|
||||||
|
match path {
|
||||||
|
"/mock" => {
|
||||||
|
node.title = "mock".to_string();
|
||||||
|
node.children = vec![LibraryNodeChild {
|
||||||
|
is_downloadable: self.downloadable,
|
||||||
|
..LibraryNodeChild::new("/mock/a".to_string(), "a".to_string(), true)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
"/mock/a" => {
|
||||||
|
node.title = "a".to_string();
|
||||||
|
node.is_queable = true;
|
||||||
|
node.tracks = vec![self.track("/mock/a/1"), self.track("/mock/a/2")];
|
||||||
|
}
|
||||||
|
_ => return Err(ProviderError::MalformedPath),
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
_parent_path: &str,
|
||||||
|
_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
async fn rename_lib_node(
|
||||||
|
&self,
|
||||||
|
_path: &str,
|
||||||
|
_new_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store() -> (CaptureStore, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("store tempdir");
|
||||||
|
let store = CaptureStore::open(dir.path().join("captures"))
|
||||||
|
.await
|
||||||
|
.expect("open creates the directory");
|
||||||
|
(store, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = fs::read_dir(dir)
|
||||||
|
.expect("dir")
|
||||||
|
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| !n.starts_with('.'))
|
||||||
|
.collect();
|
||||||
|
names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A silent progress reporter for tests that do not assert on events.
|
||||||
|
fn silent() -> Progress {
|
||||||
|
Progress::silent("test", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the capture entry's toml and returns its playable.
|
||||||
|
fn playable_of(dir: &Path, name: &str) -> fsdy::Playable {
|
||||||
|
let text = fs::read_to_string(dir.join(name)).expect("toml");
|
||||||
|
fsdy::TrackFile::parse(&text)
|
||||||
|
.expect("parses")
|
||||||
|
.playable()
|
||||||
|
.expect("playable")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_capture_writes_audio_next_to_pointing_tomls() {
|
||||||
|
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
|
||||||
|
let root = store.dir().join("faves");
|
||||||
|
assert_eq!(
|
||||||
|
visible(&root),
|
||||||
|
vec![
|
||||||
|
"0001 one.cbd-track.toml".to_string(),
|
||||||
|
"0001 one.flac".into(),
|
||||||
|
"0002 two.cbd-track.toml".into(),
|
||||||
|
"0002 two.flac".into(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||||
|
b"flacbytes"
|
||||||
|
);
|
||||||
|
// The toml points at its sibling with a *relative* file playable.
|
||||||
|
assert_eq!(
|
||||||
|
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||||
|
fsdy::Playable::File("0002 two.flac".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replay: the capture resolves through a /captures instance and the
|
||||||
|
// audio resolves to the absolute sibling path — no provider round
|
||||||
|
// trip left.
|
||||||
|
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||||
|
.expect("captures instance");
|
||||||
|
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||||
|
captures
|
||||||
|
.resolve_tracks_into("/captures/faves", chunk_tx)
|
||||||
|
.await
|
||||||
|
.expect("resolve");
|
||||||
|
let tracks: Vec<Track> = chunk_rx.into_iter().flatten().collect();
|
||||||
|
assert_eq!(tracks.len(), 2);
|
||||||
|
let urls = captures
|
||||||
|
.get_urls_for_track(&tracks[0].path)
|
||||||
|
.await
|
||||||
|
.expect("urls");
|
||||||
|
assert_eq!(urls, vec![root.join("0001 one.flac").display().to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_requires_the_root_download_blessing() {
|
||||||
|
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], false);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
// validate() is what the accept-then-stream RPC checks up front...
|
||||||
|
assert!(matches!(
|
||||||
|
store.validate(&mock, "/mock/a", "faves").await,
|
||||||
|
Err(CaptureError::Unsupported)
|
||||||
|
));
|
||||||
|
// ...and the walk itself re-checks, for both node and track roots.
|
||||||
|
assert!(matches!(
|
||||||
|
store.capture(&mock, "/mock/a", "faves", &silent()).await,
|
||||||
|
Err(CaptureError::Unsupported)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.capture(&mock, "/mock/a/1", "faves", &silent()).await,
|
||||||
|
Err(CaptureError::Unsupported)
|
||||||
|
));
|
||||||
|
assert_eq!(fs::read_dir(store.dir()).expect("store dir").count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capturing_a_single_blessed_track_writes_one_pair() {
|
||||||
|
let url = serve("200 OK", "audio/mpeg", b"mp3bytes".to_vec()).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a/1", "just one", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture track");
|
||||||
|
assert_eq!(
|
||||||
|
visible(&store.dir().join("just one")),
|
||||||
|
vec!["0001 one.cbd-track.toml".to_string(), "0001 one.mp3".into()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_failure_keeps_progress_and_resuming_completes() {
|
||||||
|
let ok = serve("200 OK", "audio/flac", b"first".to_vec()).await;
|
||||||
|
let gone = serve("404 Not Found", "text/plain", Vec::new()).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &gone)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let err = store
|
||||||
|
.capture(&mock, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect_err("a failed download aborts the run");
|
||||||
|
assert!(matches!(err, CaptureError::Download(_)), "got {err:?}");
|
||||||
|
// Incremental: what downloaded before the failure survives
|
||||||
|
// (architecture/incremental-captures.md D2).
|
||||||
|
let root = store.dir().join("faves");
|
||||||
|
assert!(root.join("0001 one.cbd-track.toml").exists());
|
||||||
|
assert!(root.join("0001 one.flac").exists());
|
||||||
|
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
||||||
|
|
||||||
|
// Resume with a healthy source: the satisfied entry is reused (the
|
||||||
|
// sentinel content is not re-downloaded), the missing one arrives.
|
||||||
|
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
||||||
|
let fixed = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &ok)], true);
|
||||||
|
store
|
||||||
|
.capture(&fixed, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("resume completes");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||||
|
b"sentinel",
|
||||||
|
"satisfied entries must not be re-downloaded"
|
||||||
|
);
|
||||||
|
assert!(root.join("0002 two.cbd-track.toml").exists());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("0002 two.flac")).expect("audio"),
|
||||||
|
b"first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_capture_enforces_its_caps() {
|
||||||
|
let url = serve("200 OK", "audio/flac", b"0123456789".to_vec()).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
// Byte budget: two 10-byte bodies against a 15-byte budget. The
|
||||||
|
// first track fits, the second trips the budget — and survives as
|
||||||
|
// partial progress (no toml, so a resume re-downloads it).
|
||||||
|
let caps = Caps {
|
||||||
|
max_bytes: 15,
|
||||||
|
..DOWNLOAD_CAPS
|
||||||
|
};
|
||||||
|
let err = store
|
||||||
|
.capture_with_caps(&mock, "/mock/a", "big", caps, &silent())
|
||||||
|
.await
|
||||||
|
.expect_err("over the byte budget");
|
||||||
|
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
||||||
|
let root = store.dir().join("big");
|
||||||
|
assert!(root.join("0001 one.cbd-track.toml").exists());
|
||||||
|
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
||||||
|
// Track cap: enumeration fails before anything is fetched.
|
||||||
|
let caps = Caps {
|
||||||
|
max_tracks: 1,
|
||||||
|
..DOWNLOAD_CAPS
|
||||||
|
};
|
||||||
|
let err = store
|
||||||
|
.capture_with_caps(&mock, "/mock/a", "big2", caps, &silent())
|
||||||
|
.await
|
||||||
|
.expect_err("over the track cap");
|
||||||
|
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
||||||
|
assert_eq!(visible(&store.dir().join("big2")), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
|
||||||
|
// A queue-like source: a downloadable fsdy instance whose folder
|
||||||
|
// mixes an http track with a local-file track — exactly a
|
||||||
|
// persisted queue linking tidal and fs entries. Both must end up
|
||||||
|
// with local audio: the web one downloaded, the fs one copied.
|
||||||
|
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||||
|
let src = TempDir::new().expect("source tempdir");
|
||||||
|
let mix = src.path().join("mix");
|
||||||
|
fs::create_dir_all(&mix).expect("mkdir");
|
||||||
|
fs::write(
|
||||||
|
mix.join("01 web.cbd-track.toml"),
|
||||||
|
format!("title = \"web\"\n[playable]\nurl = {url:?}\n"),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
fs::write(mix.join("audio.flac"), b"local").expect("write");
|
||||||
|
fs::write(
|
||||||
|
mix.join("02 local.cbd-track.toml"),
|
||||||
|
"title = \"local\"\n[playable]\nfile = \"audio.flac\"\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let source = fsdy::Client::new("/queues", src.path().to_path_buf())
|
||||||
|
.expect("source instance")
|
||||||
|
.with_downloadable_nodes();
|
||||||
|
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&source, "/queues/mix", "mixed", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
// Both tracks captured with audio next to their toml: the web
|
||||||
|
// track downloaded, the local track copied in.
|
||||||
|
let root = store.dir().join("mixed");
|
||||||
|
assert_eq!(
|
||||||
|
visible(&root),
|
||||||
|
vec![
|
||||||
|
"0001 web.cbd-track.toml".to_string(),
|
||||||
|
"0001 web.flac".into(),
|
||||||
|
"0002 local.cbd-track.toml".into(),
|
||||||
|
"0002 local.flac".into(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
match playable_of(&root, "0002 local.cbd-track.toml") {
|
||||||
|
fsdy::Playable::File(rel) => {
|
||||||
|
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
|
||||||
|
}
|
||||||
|
other => panic!("expected the local track copied, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Neither lists as skipped.
|
||||||
|
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||||
|
.expect("captures instance");
|
||||||
|
let node = captures
|
||||||
|
.get_lib_node("/captures/mixed")
|
||||||
|
.await
|
||||||
|
.expect("node");
|
||||||
|
assert_eq!(node.tracks.len(), 2);
|
||||||
|
assert!(node.tracks.iter().all(|t| !t.is_skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skipped_entries_are_reattempted_on_resume() {
|
||||||
|
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||||
|
// Track two has no stream URL: recorded as skipped.
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture with a skipped entry");
|
||||||
|
let root = store.dir().join("faves");
|
||||||
|
assert_eq!(
|
||||||
|
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||||
|
fsdy::Playable::Skipped
|
||||||
|
);
|
||||||
|
|
||||||
|
// The source became capturable: re-capturing the same name
|
||||||
|
// completes the skipped entry and reuses the satisfied one.
|
||||||
|
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
||||||
|
let fixed = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||||
|
store
|
||||||
|
.capture(&fixed, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("resume");
|
||||||
|
assert_eq!(
|
||||||
|
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||||
|
fsdy::Playable::File("0002 two.flac".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||||
|
b"sentinel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_validates_names_and_merges_into_existing() {
|
||||||
|
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
assert!(matches!(
|
||||||
|
store.validate(&mock, "/mock/a", "a/b").await,
|
||||||
|
Err(CaptureError::InvalidName(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.capture(&mock, "/mock/a", "a/b", &silent()).await,
|
||||||
|
Err(CaptureError::InvalidName(_))
|
||||||
|
));
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("first capture");
|
||||||
|
// Capturing a smaller source into the same name merges: the
|
||||||
|
// single-track capture reuses its entry, the rest stays.
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a/1", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("merge");
|
||||||
|
assert_eq!(
|
||||||
|
visible(&store.dir().join("faves")),
|
||||||
|
vec![
|
||||||
|
"0001 one.cbd-track.toml".to_string(),
|
||||||
|
"0001 one.flac".into(),
|
||||||
|
"0002 two.cbd-track.toml".into(),
|
||||||
|
"0002 two.flac".into(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn downloads_chain_bounded_windows_on_strict_cdns() {
|
||||||
|
// A googlevideo-style server: only bounded ranges under 16 bytes
|
||||||
|
// are served; plain or oversized requests are 403. A downloader
|
||||||
|
// with a 10-byte window must fetch the 100-byte body completely.
|
||||||
|
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
||||||
|
let url = serve_ranged("audio/flac", body.clone(), 16).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||||
|
let dir = TempDir::new().expect("store tempdir");
|
||||||
|
let store_dir = dir.path().join("captures");
|
||||||
|
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
|
||||||
|
let store = CaptureStore {
|
||||||
|
dir: store_dir,
|
||||||
|
sink: crate::capture::Sink::Download(
|
||||||
|
crate::capture::Downloader::with_window(10).expect("downloader"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a/1", "windowed", &silent())
|
||||||
|
.await
|
||||||
|
.expect("windowed capture");
|
||||||
|
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
|
||||||
|
assert_eq!(audio, body, "all windows stitched in order");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_reports_progress_totals_and_skips() {
|
||||||
|
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||||
|
// Track two is uncapturable: one done, one skipped, total two.
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let (tx, rx) = flume::bounded(64);
|
||||||
|
let progress = Progress::new("faves", true, tx);
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a", "faves", &progress)
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
progress.finish(None).await;
|
||||||
|
let events: Vec<_> = rx.drain().collect();
|
||||||
|
assert!(events.iter().any(|e| e.tracks_total == 2 && !e.finished));
|
||||||
|
let last = events.last().expect("terminal event");
|
||||||
|
assert!(last.finished);
|
||||||
|
assert!(last.error.is_empty());
|
||||||
|
assert_eq!(last.tracks_done, 2, "skipped counts toward done");
|
||||||
|
assert_eq!(last.tracks_skipped, 1);
|
||||||
|
assert!(last.download);
|
||||||
|
assert_eq!(last.name, "faves");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_file_playables_are_copied_not_skipped() {
|
||||||
|
// An fs-style playable: the provider resolves the track to a
|
||||||
|
// local file path (not an http URL). The capture must copy that
|
||||||
|
// audio in, not record the track as skipped.
|
||||||
|
let src = TempDir::new().expect("source dir");
|
||||||
|
let audio = src.path().join("song.flac");
|
||||||
|
fs::write(&audio, b"flacdata").expect("write source audio");
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
|
||||||
|
let (store, dir) = store().await;
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a", "faves", &silent())
|
||||||
|
.await
|
||||||
|
.expect("capture");
|
||||||
|
|
||||||
|
let capture_dir = dir.path().join("captures").join("faves");
|
||||||
|
// Track one ("one"): a File playable whose audio was copied in
|
||||||
|
// next to the toml, with the source extension and contents.
|
||||||
|
let one_toml = fsdy::track_file_name(0, "one");
|
||||||
|
match playable_of(&capture_dir, &one_toml) {
|
||||||
|
fsdy::Playable::File(rel) => {
|
||||||
|
let copied = capture_dir.join(&rel);
|
||||||
|
assert!(copied.exists(), "audio copied next to the toml");
|
||||||
|
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
|
||||||
|
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
|
||||||
|
}
|
||||||
|
other => panic!("expected a copied File playable, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Track two ("two") has no source and is still recorded skipped.
|
||||||
|
let two_toml = fsdy::track_file_name(1, "two");
|
||||||
|
assert!(matches!(
|
||||||
|
playable_of(&capture_dir, &two_toml),
|
||||||
|
fsdy::Playable::Skipped
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,308 @@
|
||||||
use crabidy_core::proto::crabidy::{Queue, Track};
|
pub mod auth;
|
||||||
|
pub mod bookmark_store;
|
||||||
|
#[cfg(feature = "web-ui")]
|
||||||
|
pub mod web;
|
||||||
|
|
||||||
|
pub mod capture;
|
||||||
|
pub mod capture_store;
|
||||||
|
pub mod playback;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod queue_store;
|
||||||
|
pub mod rpc;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod spectrum;
|
||||||
|
|
||||||
|
use audio_player::PlayerMessage;
|
||||||
|
use crabidy_core::proto::crabidy::{
|
||||||
|
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
||||||
|
Track,
|
||||||
|
};
|
||||||
|
use crabidy_core::{ProviderClient, ProviderError};
|
||||||
use rand::{rng, seq::SliceRandom};
|
use rand::{rng, seq::SliceRandom};
|
||||||
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error, info, instrument, warn, Span};
|
||||||
|
|
||||||
|
/// The gRPC listen address of the server.
|
||||||
|
pub const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||||||
|
|
||||||
|
/// Builds and runs the whole server stack on `addr`: provider
|
||||||
|
/// orchestrator, queue persistence, playback loop, player message
|
||||||
|
/// forwarder, and the tonic gRPC service. Runs until the server is shut
|
||||||
|
/// down or fails.
|
||||||
|
///
|
||||||
|
/// Extracted from the `crabidy-server` binary so the bundled `cbd`
|
||||||
|
/// binary can host the same server in-process
|
||||||
|
/// (architecture/cbd-bundle.md D1). Errors are returned, never
|
||||||
|
/// panicked: a failed provider init or an occupied port is the
|
||||||
|
/// caller's decision.
|
||||||
|
pub async fn serve(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// Auth first: a malformed crabidy-server.toml must abort startup
|
||||||
|
// instead of running an intended-to-be-locked server open
|
||||||
|
// (architecture/roles-auth.md). A missing file runs open.
|
||||||
|
let config_dir = dirs::config_dir()
|
||||||
|
.map(|d| d.join("crabidy"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||||
|
let server_settings = settings::ServerSettings::load(&config_dir)?;
|
||||||
|
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
|
||||||
|
if authenticator.enabled() {
|
||||||
|
info!("role authorization enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||||
|
let orchestrator = provider::ProviderOrchestrator::init("")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("failed to init provider orchestrator: {err}");
|
||||||
|
err
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Queue persistence is optional: without a usable queues directory the
|
||||||
|
// server runs with an in-memory queue only.
|
||||||
|
let queue_store = match queue_store::queues_dir() {
|
||||||
|
Some(dir) => match queue_store::QueueStore::open(dir).await {
|
||||||
|
Ok(store) => Some(Arc::new(store)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("queue persistence disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("queue persistence disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let playback = playback::Playback::new(
|
||||||
|
update_tx.clone(),
|
||||||
|
orchestrator.provider_tx.clone(),
|
||||||
|
queue_store,
|
||||||
|
);
|
||||||
|
// Reload the persisted current queue before anything can observe or
|
||||||
|
// mutate state; never starts playback.
|
||||||
|
playback.restore_current().await;
|
||||||
|
|
||||||
|
let playback_tx = playback.playback_tx.clone();
|
||||||
|
let player_msg = playback.player.messages.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
poll_play_bus(player_msg, playback_tx);
|
||||||
|
});
|
||||||
|
info!("player message forwarder started");
|
||||||
|
|
||||||
|
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
||||||
|
|
||||||
|
let crabidy_service = rpc::RpcService::new(
|
||||||
|
update_tx,
|
||||||
|
playback.playback_tx.clone(),
|
||||||
|
orchestrator.provider_tx.clone(),
|
||||||
|
);
|
||||||
|
orchestrator.run();
|
||||||
|
info!("provider orchestrator started");
|
||||||
|
playback.run();
|
||||||
|
info!("playback started");
|
||||||
|
|
||||||
|
let router = build_router(crabidy_service, authenticator);
|
||||||
|
|
||||||
|
info!(%addr, "grpc server listening");
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, router).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composes the one axum router that serves everything on one port: the
|
||||||
|
/// gRPC service (native HTTP/2 for the TUI *and*, with `web-ui`,
|
||||||
|
/// gRPC-web for the browser through the tonic-web layer) plus, with
|
||||||
|
/// `web-ui`, the embedded web client as the fallback route
|
||||||
|
/// (architecture/web-client.md).
|
||||||
|
///
|
||||||
|
/// The auth layer wraps only the gRPC route — its default-deny is for
|
||||||
|
/// RPC methods; the app shell itself is public, like any login page.
|
||||||
|
/// Kept separate from [`serve`] so the routing/auth composition is
|
||||||
|
/// testable without a live provider backend.
|
||||||
|
pub fn build_router(
|
||||||
|
crabidy_service: rpc::RpcService,
|
||||||
|
authenticator: Arc<auth::Authenticator>,
|
||||||
|
) -> axum::Router {
|
||||||
|
let builder = tower::ServiceBuilder::new().layer(auth::AuthLayer::new(authenticator));
|
||||||
|
#[cfg(feature = "web-ui")]
|
||||||
|
let builder = builder.layer(tonic_web::GrpcWebLayer::new());
|
||||||
|
let grpc = builder.service(CrabidyServiceServer::new(crabidy_service));
|
||||||
|
let router = axum::Router::new().route_service(
|
||||||
|
&format!(
|
||||||
|
"/{}/{{*method}}",
|
||||||
|
<CrabidyServiceServer<rpc::RpcService> as tonic::server::NamedService>::NAME
|
||||||
|
),
|
||||||
|
grpc,
|
||||||
|
);
|
||||||
|
#[cfg(feature = "web-ui")]
|
||||||
|
let router = router.fallback(web::serve_asset);
|
||||||
|
router
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spectrum FFT loop (architecture/spectrum.md): ~20 fps, snapshots
|
||||||
|
/// the player's sample tap, folds it into frequency bars, and
|
||||||
|
/// broadcasts them. Cheap and gated: it skips ticks with no stream
|
||||||
|
/// subscribers, and only recomputes when the tap advanced since the
|
||||||
|
/// last tick (audio is flowing), emitting a single zero frame when
|
||||||
|
/// playback goes idle so the bars fall rather than freeze.
|
||||||
|
fn spawn_spectrum_task(
|
||||||
|
tap: std::sync::Arc<audio_player::SpectrumTap>,
|
||||||
|
update_tx: tokio::sync::broadcast::Sender<
|
||||||
|
crabidy_core::proto::crabidy::get_update_stream_response::Update,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
use crabidy_core::proto::crabidy::{get_update_stream_response::Update, SpectrumFrame};
|
||||||
|
|
||||||
|
const FPS: u64 = 20;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut analyzer = spectrum::SpectrumAnalyzer::new(audio_player::SPECTRUM_WINDOW);
|
||||||
|
let mut last_count = tap.frame_count();
|
||||||
|
let mut was_active = false;
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000 / FPS));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
// Nobody watching: do no work.
|
||||||
|
if update_tx.receiver_count() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let count = tap.frame_count();
|
||||||
|
if count != last_count {
|
||||||
|
last_count = count;
|
||||||
|
if !was_active {
|
||||||
|
debug!("spectrum: audio flowing, streaming bars");
|
||||||
|
}
|
||||||
|
was_active = true;
|
||||||
|
let bins = analyzer.analyze(&tap.snapshot());
|
||||||
|
let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins }));
|
||||||
|
} else if was_active {
|
||||||
|
// Playback just went idle: drop the bars to the floor once.
|
||||||
|
debug!("spectrum: audio idle, bars to zero");
|
||||||
|
was_active = false;
|
||||||
|
let _ = update_tx.send(Update::Spectrum(SpectrumFrame {
|
||||||
|
bins: vec![0.0; spectrum::SPECTRUM_BINS],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards player engine events into the playback message loop.
|
||||||
|
#[instrument(skip(rx, tx))]
|
||||||
|
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||||
|
for msg in rx.iter() {
|
||||||
|
let command = match msg {
|
||||||
|
PlayerMessage::EndOfStream => {
|
||||||
|
debug!("player reported end of stream");
|
||||||
|
PlaybackCommand::Next
|
||||||
|
}
|
||||||
|
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
||||||
|
state: PlayState::Stopped,
|
||||||
|
},
|
||||||
|
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
||||||
|
state: PlayState::Paused,
|
||||||
|
},
|
||||||
|
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
||||||
|
state: PlayState::Playing,
|
||||||
|
},
|
||||||
|
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
||||||
|
duration: duration.as_millis() as u32,
|
||||||
|
position: elapsed.as_millis() as u32,
|
||||||
|
},
|
||||||
|
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
||||||
|
duration: duration.as_millis() as u32,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
||||||
|
error!("failed to forward player message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn!("player message channel closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a pending queue operation places its resolved chunks.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ResolveKind {
|
||||||
|
/// First chunk replaces the whole queue, later chunks append.
|
||||||
|
Replace,
|
||||||
|
/// Every chunk appends at the end.
|
||||||
|
Append,
|
||||||
|
/// Chunks insert after the given position, each advancing the cursor so
|
||||||
|
/// the resolved collection stays contiguous and in order. `Queue`
|
||||||
|
/// (play-after-current) is an `InsertAfter` at the current position.
|
||||||
|
InsertAfter(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The playback loop's bookkeeping for one in-flight resolve operation.
|
||||||
|
///
|
||||||
|
/// Created when a `Replace`/`Queue`/`Append`/`Insert` command arrives,
|
||||||
|
/// dropped when its forwarder reports completion or a `Replace`/`Clear`
|
||||||
|
/// cancels it. Chunk application happens exclusively on the playback loop,
|
||||||
|
/// which keeps the loop the single writer of queue state.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PendingResolve {
|
||||||
|
kind: ResolveKind,
|
||||||
|
/// Tracks applied so far; an op finishing at zero is worth a warning.
|
||||||
|
applied: usize,
|
||||||
|
/// Shared with the op's forwarder task: set on cancellation so the
|
||||||
|
/// forwarder drops the chunk receiver, which stops the provider fetch.
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingResolve {
|
||||||
|
pub fn new(kind: ResolveKind) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
applied: 0,
|
||||||
|
cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cancellation flag to hand to this op's forwarder task.
|
||||||
|
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
|
||||||
|
Arc::clone(&self.cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the op cancelled so its forwarder stops feeding chunks.
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.cancelled
|
||||||
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total tracks applied by this op so far.
|
||||||
|
pub fn applied(&self) -> usize {
|
||||||
|
self.applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one resolved chunk to the queue and advances this op's
|
||||||
|
/// cursor. Returns the track that should start playing, if this chunk
|
||||||
|
/// made one current (first chunk of a replace, or any chunk landing in
|
||||||
|
/// an empty queue) — later chunks of the same op never restart playback.
|
||||||
|
pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> {
|
||||||
|
self.applied += tracks.len();
|
||||||
|
match self.kind {
|
||||||
|
ResolveKind::Replace => {
|
||||||
|
// Only the first chunk replaces; the rest of this op
|
||||||
|
// extends the fresh queue.
|
||||||
|
self.kind = ResolveKind::Append;
|
||||||
|
queue.replace_with_tracks(tracks)
|
||||||
|
}
|
||||||
|
ResolveKind::Append => queue.append_tracks(tracks),
|
||||||
|
ResolveKind::InsertAfter(position) => {
|
||||||
|
// Advance the cursor so this op's next chunk lands right
|
||||||
|
// behind this one, keeping the collection contiguous.
|
||||||
|
// `insert_tracks` clamps positions past the end.
|
||||||
|
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
|
||||||
|
queue.insert_tracks(position, tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct QueueManager {
|
pub struct QueueManager {
|
||||||
|
|
@ -24,6 +325,10 @@ impl From<QueueManager> for Queue {
|
||||||
.as_secs(),
|
.as_secs(),
|
||||||
current_position: queue_manager.current_position() as u32,
|
current_position: queue_manager.current_position() as u32,
|
||||||
tracks: queue_manager.tracks,
|
tracks: queue_manager.tracks,
|
||||||
|
// The manager cannot know about in-flight resolves; the
|
||||||
|
// playback loop's broadcast path sets this from its pending-op
|
||||||
|
// map (see `Playback::broadcast_queue`).
|
||||||
|
resolving: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +358,16 @@ impl QueueManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of tracks in the queue (playback uses it to bound skip
|
||||||
|
/// loops: at most one full pass, even with repeat on).
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.tracks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tracks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_last_track(&self) -> bool {
|
pub fn is_last_track(&self) -> bool {
|
||||||
!self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1
|
!self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +625,7 @@ mod tests {
|
||||||
title: format!("track {id}"),
|
title: format!("track {id}"),
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
|
is_skipped: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,6 +706,69 @@ mod tests {
|
||||||
assert_eq!(q.tracks.last().unwrap().title, "track 2");
|
assert_eq!(q.tracks.last().unwrap().title, "track 2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn titles(q: &QueueManager) -> Vec<String> {
|
||||||
|
q.tracks.iter().map(|t| t.title.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_op_replaces_first_then_appends_and_plays_once() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
let mut op = PendingResolve::new(ResolveKind::Replace);
|
||||||
|
let first = op.apply_chunk(&mut q, &[track(10), track(11)]);
|
||||||
|
// The first chunk resets the queue and names the track to start.
|
||||||
|
assert_eq!(first.unwrap().title, "track 10");
|
||||||
|
assert_eq!(titles(&q), vec!["track 10", "track 11"]);
|
||||||
|
let second = op.apply_chunk(&mut q, &[track(12)]);
|
||||||
|
// Later chunks extend the same replace without restarting playback.
|
||||||
|
assert!(second.is_none());
|
||||||
|
assert_eq!(titles(&q), vec!["track 10", "track 11", "track 12"]);
|
||||||
|
assert_eq!(op.applied(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_after_op_keeps_chunks_contiguous_and_in_order() {
|
||||||
|
let mut q = queue_with(3); // playing track 0
|
||||||
|
let mut op = PendingResolve::new(ResolveKind::InsertAfter(0));
|
||||||
|
assert!(op.apply_chunk(&mut q, &[track(10), track(11)]).is_none());
|
||||||
|
assert!(op.apply_chunk(&mut q, &[track(12)]).is_none());
|
||||||
|
// Both chunks sit as one contiguous run right after the current
|
||||||
|
// track, in arrival order — not interleaved with the old tail.
|
||||||
|
assert_eq!(
|
||||||
|
titles(&q),
|
||||||
|
vec!["track 0", "track 10", "track 11", "track 12", "track 1", "track 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_after_op_clamps_past_the_end() {
|
||||||
|
let mut q = queue_with(1);
|
||||||
|
let mut op = PendingResolve::new(ResolveKind::InsertAfter(99));
|
||||||
|
assert!(op.apply_chunk(&mut q, &[track(10)]).is_none());
|
||||||
|
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
|
||||||
|
assert_eq!(titles(&q), vec!["track 0", "track 10", "track 11"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_op_starts_playback_only_into_an_empty_queue() {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
let mut op = PendingResolve::new(ResolveKind::Append);
|
||||||
|
let first = op.apply_chunk(&mut q, &[track(10)]);
|
||||||
|
// Landing in an empty queue makes the track current: play it.
|
||||||
|
assert_eq!(first.unwrap().title, "track 10");
|
||||||
|
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
|
||||||
|
assert_eq!(titles(&q), vec!["track 10", "track 11"]);
|
||||||
|
assert_eq!(op.applied(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_flag_is_shared_with_the_forwarder() {
|
||||||
|
let op = PendingResolve::new(ResolveKind::Append);
|
||||||
|
let flag = op.cancel_flag();
|
||||||
|
assert!(!flag.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
op.cancel();
|
||||||
|
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shuffle_insert_keeps_order_unique() {
|
fn shuffle_insert_keeps_order_unique() {
|
||||||
let mut q = queue_with(5);
|
let mut q = queue_with(5);
|
||||||
|
|
@ -402,3 +781,216 @@ mod tests {
|
||||||
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// A command for the provider orchestrator, tagged with the tracing span that
|
||||||
|
/// was current when it was sent so the handler can attribute its events to
|
||||||
|
/// the originating request.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProviderMessage {
|
||||||
|
pub span: Span,
|
||||||
|
pub command: ProviderCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderMessage {
|
||||||
|
pub fn new(command: ProviderCommand) -> Self {
|
||||||
|
Self {
|
||||||
|
span: Span::current(),
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ProviderCommand {
|
||||||
|
GetLibraryNode {
|
||||||
|
path: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
GetTrackUrls {
|
||||||
|
path: String,
|
||||||
|
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Resolves a path into playable tracks: a track path yields that single
|
||||||
|
/// track, a node path yields all tracks reachable below it. Streamed:
|
||||||
|
/// zero or more in-order chunks arrive on `chunk_tx`, the sender is
|
||||||
|
/// dropped when resolution finishes, and dropping the receiver cancels
|
||||||
|
/// it (see `ProviderClient::resolve_tracks_into`). The orchestrator
|
||||||
|
/// handles this command on a spawned task so its loop stays free for
|
||||||
|
/// other commands (notably `GetTrackUrls` for the first chunk's track).
|
||||||
|
ResolveTracks {
|
||||||
|
path: String,
|
||||||
|
chunk_tx: flume::Sender<Vec<Track>>,
|
||||||
|
},
|
||||||
|
/// Creates a child under a creatable node (see
|
||||||
|
/// `ProviderClient::create_lib_node`); replies with the created node.
|
||||||
|
CreateLibraryNode {
|
||||||
|
parent_path: String,
|
||||||
|
title: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Renames an editable node (see `ProviderClient::rename_lib_node`);
|
||||||
|
/// replies with the renamed node at its new path.
|
||||||
|
RenameLibraryNode {
|
||||||
|
path: String,
|
||||||
|
new_title: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Deletes a deletable node (see `ProviderClient::delete_lib_node`);
|
||||||
|
/// replies with the refreshed parent node.
|
||||||
|
DeleteLibraryNode {
|
||||||
|
path: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Captures the queueable subtree at `path` as the bookmark `name`
|
||||||
|
/// (see `architecture/bookmarks.md` D1–D3) — or, with `download`, as
|
||||||
|
/// the download capture `name` under `/captures`, fetching every
|
||||||
|
/// track's audio (see `architecture/captures.md` and
|
||||||
|
/// `architecture/incremental-captures.md`). Handled on a spawned
|
||||||
|
/// task — a large walk or download must not block the orchestrator
|
||||||
|
/// loop. `result_tx` answers once the capture is *accepted*
|
||||||
|
/// (validation only); the walk then streams `CaptureProgress` events
|
||||||
|
/// on `progress_tx`, ending in exactly one `finished` event (with
|
||||||
|
/// `error` set on failure). A rejected capture answers with the error
|
||||||
|
/// and sends no progress events.
|
||||||
|
CaptureLibraryNode {
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
download: bool,
|
||||||
|
progress_tx: flume::Sender<crabidy_core::proto::crabidy::CaptureProgress>,
|
||||||
|
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderCommand {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::GetLibraryNode { .. } => "get_library_node",
|
||||||
|
Self::GetTrackUrls { .. } => "get_track_urls",
|
||||||
|
Self::ResolveTracks { .. } => "resolve_tracks",
|
||||||
|
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||||
|
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||||||
|
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||||||
|
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PlaybackMessage {
|
||||||
|
pub span: Span,
|
||||||
|
pub command: PlaybackCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackMessage {
|
||||||
|
pub fn new(command: PlaybackCommand) -> Self {
|
||||||
|
Self {
|
||||||
|
span: Span::current(),
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlaybackCommand {
|
||||||
|
Init {
|
||||||
|
result_tx: flume::Sender<InitResponse>,
|
||||||
|
},
|
||||||
|
Replace {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
Queue {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
Append {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
positions: Vec<u32>,
|
||||||
|
},
|
||||||
|
Insert {
|
||||||
|
position: u32,
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Internal: a resolved chunk of tracks for the pending queue operation
|
||||||
|
/// `op_id`, sent by that operation's forwarder task. Chunks for an
|
||||||
|
/// unknown (finished or cancelled) op are dropped silently.
|
||||||
|
ApplyResolvedChunk {
|
||||||
|
op_id: u64,
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
},
|
||||||
|
/// Internal: the forwarder task for `op_id` has seen the provider drop
|
||||||
|
/// its chunk sender — the operation is complete and the `resolving`
|
||||||
|
/// flag clears once no pending operations remain.
|
||||||
|
ResolveFinished {
|
||||||
|
op_id: u64,
|
||||||
|
},
|
||||||
|
Clear {
|
||||||
|
exclude_current: bool,
|
||||||
|
},
|
||||||
|
SetCurrent {
|
||||||
|
position: u32,
|
||||||
|
},
|
||||||
|
/// Saves the current queue under a name (see
|
||||||
|
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||||||
|
/// snapshot is consistent; the disk write happens on a spawned task and
|
||||||
|
/// reports through `result_tx`.
|
||||||
|
SaveQueue {
|
||||||
|
name: String,
|
||||||
|
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>,
|
||||||
|
},
|
||||||
|
ToggleShuffle,
|
||||||
|
ToggleRepeat,
|
||||||
|
TogglePlay,
|
||||||
|
Stop,
|
||||||
|
ChangeVolume {
|
||||||
|
delta: f32,
|
||||||
|
},
|
||||||
|
ToggleMute,
|
||||||
|
Next,
|
||||||
|
Prev,
|
||||||
|
RestartTrack,
|
||||||
|
StateChanged {
|
||||||
|
state: PlayState,
|
||||||
|
},
|
||||||
|
VolumeChanged {
|
||||||
|
volume: f32,
|
||||||
|
},
|
||||||
|
MuteChanged {
|
||||||
|
muted: bool,
|
||||||
|
},
|
||||||
|
PositionChanged {
|
||||||
|
duration: u32,
|
||||||
|
position: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackCommand {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Init { .. } => "init",
|
||||||
|
Self::Replace { .. } => "replace",
|
||||||
|
Self::Queue { .. } => "queue",
|
||||||
|
Self::Append { .. } => "append",
|
||||||
|
Self::Remove { .. } => "remove",
|
||||||
|
Self::Insert { .. } => "insert",
|
||||||
|
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
||||||
|
Self::ResolveFinished { .. } => "resolve_finished",
|
||||||
|
Self::Clear { .. } => "clear",
|
||||||
|
Self::SetCurrent { .. } => "set_current",
|
||||||
|
Self::SaveQueue { .. } => "save_queue",
|
||||||
|
Self::ToggleShuffle => "toggle_shuffle",
|
||||||
|
Self::ToggleRepeat => "toggle_repeat",
|
||||||
|
Self::TogglePlay => "toggle_play",
|
||||||
|
Self::Stop => "stop",
|
||||||
|
Self::ChangeVolume { .. } => "change_volume",
|
||||||
|
Self::ToggleMute => "toggle_mute",
|
||||||
|
Self::Next => "next",
|
||||||
|
Self::Prev => "prev",
|
||||||
|
Self::RestartTrack => "restart_track",
|
||||||
|
Self::StateChanged { .. } => "state_changed",
|
||||||
|
Self::VolumeChanged { .. } => "volume_changed",
|
||||||
|
Self::MuteChanged { .. } => "mute_changed",
|
||||||
|
Self::PositionChanged { .. } => "position_changed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,47 @@
|
||||||
use audio_player::PlayerMessage;
|
//! The standalone server binary: stderr tracing plus
|
||||||
use crabidy_core::proto::crabidy::{
|
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack
|
||||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
|
//! lives in the library so the bundled `cbd` binary can host it too
|
||||||
};
|
//! (architecture/cbd-bundle.md D1).
|
||||||
use crabidy_core::{ProviderClient, ProviderError};
|
|
||||||
use tracing::{debug, error, info, instrument, warn, Span};
|
use clap::Parser;
|
||||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
mod playback;
|
#[derive(Parser)]
|
||||||
use playback::Playback;
|
#[command(author, version, about)]
|
||||||
mod provider;
|
struct Cli {
|
||||||
use provider::ProviderOrchestrator;
|
#[command(subcommand)]
|
||||||
mod rpc;
|
command: Option<Command>,
|
||||||
use rpc::RpcService;
|
}
|
||||||
|
|
||||||
use tonic::{transport::Server, Result};
|
#[derive(clap::Subcommand)]
|
||||||
|
enum Command {
|
||||||
const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
/// Hash a password for the `[auth]` section of crabidy-server.toml
|
||||||
|
/// (architecture/roles-auth.md). Reads the password as one line
|
||||||
|
/// from stdin and prints the PHC string — nothing else, so output
|
||||||
|
/// can be piped. The password itself is never printed or logged.
|
||||||
|
HashPassword,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
if let Some(Command::HashPassword) = cli.command {
|
||||||
|
return hash_password();
|
||||||
|
}
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
|
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
/// Reads one line from stdin and prints its argon2 PHC hash.
|
||||||
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
|
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
error!("failed to init provider orchestrator: {err}");
|
let mut password = String::new();
|
||||||
err
|
std::io::stdin().read_line(&mut password)?;
|
||||||
})?;
|
let password = password.trim_end_matches(['\r', '\n']);
|
||||||
|
if password.is_empty() {
|
||||||
let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
|
return Err("empty password".into());
|
||||||
|
}
|
||||||
let playback_tx = playback.playback_tx.clone();
|
println!("{}", crabidy_server::auth::hash_password(password)?);
|
||||||
let player_msg = playback.player.messages.clone();
|
|
||||||
|
|
||||||
std::thread::spawn(|| {
|
|
||||||
poll_play_bus(player_msg, playback_tx);
|
|
||||||
});
|
|
||||||
info!("player message forwarder started");
|
|
||||||
|
|
||||||
let crabidy_service = RpcService::new(
|
|
||||||
update_tx,
|
|
||||||
playback.playback_tx.clone(),
|
|
||||||
orchestrator.provider_tx.clone(),
|
|
||||||
);
|
|
||||||
orchestrator.run();
|
|
||||||
info!("provider orchestrator started");
|
|
||||||
playback.run();
|
|
||||||
info!("playback started");
|
|
||||||
|
|
||||||
let addr = LISTEN_ADDR.parse()?;
|
|
||||||
info!(%addr, "grpc server listening");
|
|
||||||
Server::builder()
|
|
||||||
.add_service(CrabidyServiceServer::new(crabidy_service))
|
|
||||||
.serve(addr)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +55,7 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
|
||||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
EnvFilter::new(
|
EnvFilter::new(
|
||||||
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
|
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug",
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,181 +74,3 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
|
||||||
guard
|
guard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forwards player engine events into the playback message loop.
|
|
||||||
#[instrument(skip(rx, tx))]
|
|
||||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
|
||||||
for msg in rx.iter() {
|
|
||||||
let command = match msg {
|
|
||||||
PlayerMessage::EndOfStream => {
|
|
||||||
debug!("player reported end of stream");
|
|
||||||
PlaybackCommand::Next
|
|
||||||
}
|
|
||||||
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
|
||||||
state: PlayState::Stopped,
|
|
||||||
},
|
|
||||||
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
|
||||||
state: PlayState::Paused,
|
|
||||||
},
|
|
||||||
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
|
||||||
state: PlayState::Playing,
|
|
||||||
},
|
|
||||||
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
|
||||||
duration: duration.as_millis() as u32,
|
|
||||||
position: elapsed.as_millis() as u32,
|
|
||||||
},
|
|
||||||
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
|
||||||
duration: duration.as_millis() as u32,
|
|
||||||
position: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
|
||||||
error!("failed to forward player message: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
warn!("player message channel closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A command for the provider orchestrator, tagged with the tracing span that
|
|
||||||
/// was current when it was sent so the handler can attribute its events to
|
|
||||||
/// the originating request.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ProviderMessage {
|
|
||||||
pub span: Span,
|
|
||||||
pub command: ProviderCommand,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderMessage {
|
|
||||||
pub fn new(command: ProviderCommand) -> Self {
|
|
||||||
Self {
|
|
||||||
span: Span::current(),
|
|
||||||
command,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ProviderCommand {
|
|
||||||
GetLibraryNode {
|
|
||||||
path: String,
|
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
|
||||||
},
|
|
||||||
GetTrackUrls {
|
|
||||||
path: String,
|
|
||||||
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
|
||||||
},
|
|
||||||
/// Resolves a path into playable tracks: a track path yields that single
|
|
||||||
/// track, a node path yields all tracks reachable below it.
|
|
||||||
ResolveTracks {
|
|
||||||
path: String,
|
|
||||||
result_tx: flume::Sender<Vec<Track>>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderCommand {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::GetLibraryNode { .. } => "get_library_node",
|
|
||||||
Self::GetTrackUrls { .. } => "get_track_urls",
|
|
||||||
Self::ResolveTracks { .. } => "resolve_tracks",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PlaybackMessage {
|
|
||||||
pub span: Span,
|
|
||||||
pub command: PlaybackCommand,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackMessage {
|
|
||||||
pub fn new(command: PlaybackCommand) -> Self {
|
|
||||||
Self {
|
|
||||||
span: Span::current(),
|
|
||||||
command,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum PlaybackCommand {
|
|
||||||
Init {
|
|
||||||
result_tx: flume::Sender<InitResponse>,
|
|
||||||
},
|
|
||||||
Replace {
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Queue {
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Append {
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Remove {
|
|
||||||
positions: Vec<u32>,
|
|
||||||
},
|
|
||||||
Insert {
|
|
||||||
position: u32,
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Clear {
|
|
||||||
exclude_current: bool,
|
|
||||||
},
|
|
||||||
SetCurrent {
|
|
||||||
position: u32,
|
|
||||||
},
|
|
||||||
ToggleShuffle,
|
|
||||||
ToggleRepeat,
|
|
||||||
TogglePlay,
|
|
||||||
Stop,
|
|
||||||
ChangeVolume {
|
|
||||||
delta: f32,
|
|
||||||
},
|
|
||||||
ToggleMute,
|
|
||||||
Next,
|
|
||||||
Prev,
|
|
||||||
RestartTrack,
|
|
||||||
StateChanged {
|
|
||||||
state: PlayState,
|
|
||||||
},
|
|
||||||
VolumeChanged {
|
|
||||||
volume: f32,
|
|
||||||
},
|
|
||||||
MuteChanged {
|
|
||||||
muted: bool,
|
|
||||||
},
|
|
||||||
PositionChanged {
|
|
||||||
duration: u32,
|
|
||||||
position: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackCommand {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Init { .. } => "init",
|
|
||||||
Self::Replace { .. } => "replace",
|
|
||||||
Self::Queue { .. } => "queue",
|
|
||||||
Self::Append { .. } => "append",
|
|
||||||
Self::Remove { .. } => "remove",
|
|
||||||
Self::Insert { .. } => "insert",
|
|
||||||
Self::Clear { .. } => "clear",
|
|
||||||
Self::SetCurrent { .. } => "set_current",
|
|
||||||
Self::ToggleShuffle => "toggle_shuffle",
|
|
||||||
Self::ToggleRepeat => "toggle_repeat",
|
|
||||||
Self::TogglePlay => "toggle_play",
|
|
||||||
Self::Stop => "stop",
|
|
||||||
Self::ChangeVolume { .. } => "change_volume",
|
|
||||||
Self::ToggleMute => "toggle_mute",
|
|
||||||
Self::Next => "next",
|
|
||||||
Self::Prev => "prev",
|
|
||||||
Self::RestartTrack => "restart_track",
|
|
||||||
Self::StateChanged { .. } => "state_changed",
|
|
||||||
Self::VolumeChanged { .. } => "volume_changed",
|
|
||||||
Self::MuteChanged { .. } => "mute_changed",
|
|
||||||
Self::PositionChanged { .. } => "position_changed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
|
use crate::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
|
||||||
|
use crate::{PendingResolve, QueueManager, ResolveKind};
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use audio_player::Player;
|
use audio_player::Player;
|
||||||
use crabidy_core::proto::crabidy::QueueModifiers;
|
use crabidy_core::proto::crabidy::QueueModifiers;
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
get_update_stream_response::Update as StreamUpdate, InitResponse, PlayState, QueueTrack, Track,
|
get_update_stream_response::Update as StreamUpdate, InitResponse, PlayState,
|
||||||
TrackPosition,
|
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use crabidy_server::QueueManager;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument};
|
||||||
|
|
||||||
pub struct Playback {
|
pub struct Playback {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
|
|
@ -17,6 +20,17 @@ pub struct Playback {
|
||||||
playback_rx: flume::Receiver<PlaybackMessage>,
|
playback_rx: flume::Receiver<PlaybackMessage>,
|
||||||
queue: Mutex<QueueManager>,
|
queue: Mutex<QueueManager>,
|
||||||
state: Mutex<PlayState>,
|
state: Mutex<PlayState>,
|
||||||
|
/// In-flight resolve operations by op id. Non-empty means the broadcast
|
||||||
|
/// `Queue` snapshots carry `resolving = true`. Only the playback loop
|
||||||
|
/// touches this map (same single-writer discipline as `queue`).
|
||||||
|
pending: Mutex<HashMap<u64, PendingResolve>>,
|
||||||
|
next_op_id: AtomicU64,
|
||||||
|
/// `None` when queue persistence is disabled (no usable queues
|
||||||
|
/// directory) — the queue then lives in memory only.
|
||||||
|
store: Option<Arc<QueueStore>>,
|
||||||
|
/// Feeds the persister task; latest snapshot wins, so the loop never
|
||||||
|
/// waits on disk (architecture/queue-persistence.md D4).
|
||||||
|
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,10 +38,12 @@ impl Playback {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
|
store: Option<Arc<QueueStore>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (playback_tx, playback_rx) = flume::bounded(64);
|
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||||
let queue = Mutex::new(QueueManager::new());
|
let queue = Mutex::new(QueueManager::new());
|
||||||
let state = Mutex::new(PlayState::Stopped);
|
let state = Mutex::new(PlayState::Stopped);
|
||||||
|
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
||||||
let player = Player::default();
|
let player = Player::default();
|
||||||
Self {
|
Self {
|
||||||
update_tx,
|
update_tx,
|
||||||
|
|
@ -36,11 +52,52 @@ impl Playback {
|
||||||
playback_rx,
|
playback_rx,
|
||||||
queue,
|
queue,
|
||||||
state,
|
state,
|
||||||
|
pending: Mutex::new(HashMap::new()),
|
||||||
|
next_op_id: AtomicU64::new(0),
|
||||||
|
store,
|
||||||
|
persist_tx,
|
||||||
player,
|
player,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reloads the persisted current queue: tracks, position, and
|
||||||
|
/// shuffle/repeat. Never starts playback — a restarted server stays
|
||||||
|
/// silent. Call before [`Self::run`] so nothing observes the empty
|
||||||
|
/// queue first.
|
||||||
|
pub async fn restore_current(&self) {
|
||||||
|
let Some(store) = &self.store else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(snapshot) = store.load_current().await else {
|
||||||
|
debug!("no persisted queue, starting fresh");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// No autoplay: the track the replace would start is ignored.
|
||||||
|
let _ = queue.replace_with_tracks(&snapshot.tracks);
|
||||||
|
queue.repeat = snapshot.repeat;
|
||||||
|
// An out-of-range position (edited folder) is refused by
|
||||||
|
// `set_current_position` and playback starts at the first track.
|
||||||
|
let _ = queue.set_current_position(snapshot.current_position);
|
||||||
|
if snapshot.shuffle {
|
||||||
|
// The play order is not persisted; restoring shuffle reshuffles
|
||||||
|
// around the restored current track.
|
||||||
|
queue.shuffle_on();
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
tracks = snapshot.tracks.len(),
|
||||||
|
position = snapshot.current_position,
|
||||||
|
"restored the persisted queue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
|
if let Some(store) = &self.store {
|
||||||
|
queue_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
||||||
|
}
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
||||||
// Attribute all handler events to a span that is a child of
|
// Attribute all handler events to a span that is a child of
|
||||||
|
|
@ -78,7 +135,9 @@ impl Playback {
|
||||||
*play_state
|
*play_state
|
||||||
};
|
};
|
||||||
InitResponse {
|
InitResponse {
|
||||||
queue: Some(queue.clone().into()),
|
// Snapshot with `resolving`: a client connecting
|
||||||
|
// mid-resolve must show the indicator right away.
|
||||||
|
queue: Some(self.queue_snapshot(&queue)),
|
||||||
queue_track: Some(queue_track),
|
queue_track: Some(queue_track),
|
||||||
play_state: play_state as i32,
|
play_state: play_state as i32,
|
||||||
volume: 0.0,
|
volume: 0.0,
|
||||||
|
|
@ -97,48 +156,34 @@ impl Playback {
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Replace { paths } => {
|
PlaybackCommand::Replace { paths } => {
|
||||||
let all_tracks = self.resolve_tracks(paths).await;
|
// A replace obsoletes whatever earlier ops are still
|
||||||
debug!(count = all_tracks.len(), "replacing queue");
|
// resolving; their late chunks must not land in the new
|
||||||
let current = {
|
// queue.
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
self.cancel_pending_resolves();
|
||||||
error!("queue lock poisoned");
|
self.start_resolve(ResolveKind::Replace, paths);
|
||||||
return;
|
|
||||||
};
|
|
||||||
queue.replace_with_tracks(&all_tracks);
|
|
||||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
|
||||||
queue.current_track()
|
|
||||||
};
|
|
||||||
self.play(current).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Queue { paths } => {
|
PlaybackCommand::Queue { paths } => {
|
||||||
let all_tracks = self.resolve_tracks(paths).await;
|
let position = {
|
||||||
debug!(count = all_tracks.len(), "queueing after current");
|
let Ok(queue) = self.queue.lock() else {
|
||||||
let track = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
|
||||||
error!("queue lock poisoned");
|
error!("queue lock poisoned");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let track = queue.queue_tracks(&all_tracks);
|
queue.current_position() as u32
|
||||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
|
||||||
track
|
|
||||||
};
|
};
|
||||||
self.play_if_some(track).await;
|
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Append { paths } => {
|
PlaybackCommand::Append { paths } => {
|
||||||
let all_tracks = self.resolve_tracks(paths).await;
|
self.start_resolve(ResolveKind::Append, paths);
|
||||||
debug!(count = all_tracks.len(), "appending to queue");
|
}
|
||||||
let track = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
PlaybackCommand::ApplyResolvedChunk { op_id, tracks } => {
|
||||||
error!("queue lock poisoned");
|
self.apply_resolved_chunk(op_id, tracks).await;
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
let track = queue.append_tracks(&all_tracks);
|
PlaybackCommand::ResolveFinished { op_id } => {
|
||||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
self.finish_resolve(op_id);
|
||||||
track
|
|
||||||
};
|
|
||||||
self.play_if_some(track).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Remove { positions } => {
|
PlaybackCommand::Remove { positions } => {
|
||||||
|
|
@ -150,7 +195,7 @@ impl Playback {
|
||||||
};
|
};
|
||||||
let was_last = queue.is_last_track();
|
let was_last = queue.is_last_track();
|
||||||
let track = queue.remove_tracks(&positions);
|
let track = queue.remove_tracks(&positions);
|
||||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
self.broadcast_queue(&queue);
|
||||||
(track, was_last)
|
(track, was_last)
|
||||||
};
|
};
|
||||||
let state = {
|
let state = {
|
||||||
|
|
@ -172,29 +217,21 @@ impl Playback {
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Insert { position, paths } => {
|
PlaybackCommand::Insert { position, paths } => {
|
||||||
let all_tracks = self.resolve_tracks(paths).await;
|
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||||
debug!(count = all_tracks.len(), position, "inserting into queue");
|
|
||||||
let track = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
|
||||||
error!("queue lock poisoned");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let track = queue.insert_tracks(position, &all_tracks);
|
|
||||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
|
||||||
track
|
|
||||||
};
|
|
||||||
self.play_if_some(track).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Clear { exclude_current } => {
|
PlaybackCommand::Clear { exclude_current } => {
|
||||||
debug!(exclude_current, "clearing queue");
|
debug!(exclude_current, "clearing queue");
|
||||||
|
// Chunks still resolving would repopulate the queue the
|
||||||
|
// user just emptied.
|
||||||
|
self.cancel_pending_resolves();
|
||||||
let should_stop = {
|
let should_stop = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
error!("queue lock poisoned");
|
error!("queue lock poisoned");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let should_stop = queue.clear(exclude_current);
|
let should_stop = queue.clear(exclude_current);
|
||||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
self.broadcast_queue(&queue);
|
||||||
should_stop
|
should_stop
|
||||||
};
|
};
|
||||||
if should_stop {
|
if should_stop {
|
||||||
|
|
@ -215,6 +252,39 @@ impl Playback {
|
||||||
self.play(track).await;
|
self.play(track).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::SaveQueue { name, result_tx } => {
|
||||||
|
debug!(name, "saving the queue");
|
||||||
|
// Snapshot on the loop (single-writer discipline), write on
|
||||||
|
// a spawned task — the loop never waits on disk.
|
||||||
|
let snapshot = {
|
||||||
|
let Ok(queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let proto: ProtoQueue = queue.clone().into();
|
||||||
|
QueueSnapshot {
|
||||||
|
tracks: proto.tracks,
|
||||||
|
current_position: proto.current_position,
|
||||||
|
repeat: queue.repeat,
|
||||||
|
shuffle: queue.shuffle,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let store = self.store.clone();
|
||||||
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
|
let result = match &store {
|
||||||
|
Some(store) => store.save(&name, &snapshot).await,
|
||||||
|
None => Err(SaveQueueError::Disabled),
|
||||||
|
};
|
||||||
|
if let Err(err) = &result {
|
||||||
|
warn!(name, "cannot save queue: {err}");
|
||||||
|
}
|
||||||
|
let _ = result_tx.send_async(result).await;
|
||||||
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackCommand::ToggleShuffle => {
|
PlaybackCommand::ToggleShuffle => {
|
||||||
let (shuffle, repeat) = {
|
let (shuffle, repeat) = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
|
@ -226,6 +296,7 @@ impl Playback {
|
||||||
} else {
|
} else {
|
||||||
queue.shuffle_on()
|
queue.shuffle_on()
|
||||||
}
|
}
|
||||||
|
self.send_persist_snapshot(&queue);
|
||||||
(queue.shuffle, queue.repeat)
|
(queue.shuffle, queue.repeat)
|
||||||
};
|
};
|
||||||
debug!(shuffle, "toggled shuffle");
|
debug!(shuffle, "toggled shuffle");
|
||||||
|
|
@ -239,6 +310,7 @@ impl Playback {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
queue.repeat = !queue.repeat;
|
queue.repeat = !queue.repeat;
|
||||||
|
self.send_persist_snapshot(&queue);
|
||||||
(queue.shuffle, queue.repeat)
|
(queue.shuffle, queue.repeat)
|
||||||
};
|
};
|
||||||
debug!(repeat, "toggled repeat");
|
debug!(repeat, "toggled repeat");
|
||||||
|
|
@ -280,10 +352,13 @@ impl Playback {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::ToggleMute => {
|
PlaybackCommand::ToggleMute => match self.player.toggle_mute().await {
|
||||||
// FIXME: implement mute in the player engine
|
Ok(muted) => {
|
||||||
debug!("toggle mute requested (not implemented)");
|
debug!(muted, "toggled mute");
|
||||||
|
self.broadcast(StreamUpdate::Mute(muted));
|
||||||
}
|
}
|
||||||
|
Err(err) => warn!("toggle_mute failed: {err:?}"),
|
||||||
|
},
|
||||||
|
|
||||||
PlaybackCommand::Next => {
|
PlaybackCommand::Next => {
|
||||||
let track = {
|
let track = {
|
||||||
|
|
@ -360,32 +435,192 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a mixed list of track and node paths into tracks by asking
|
/// Registers a pending resolve operation and spawns its forwarder task.
|
||||||
/// the provider orchestrator.
|
///
|
||||||
async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
|
/// The forwarder resolves `paths` one after the other (preserving the
|
||||||
let mut all_tracks = Vec::new();
|
/// request's path order): for each path it sends
|
||||||
for path in paths {
|
/// `ProviderCommand::ResolveTracks` with a fresh bounded chunk channel
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
/// and forwards every chunk to the playback loop as
|
||||||
|
/// `PlaybackCommand::ApplyResolvedChunk`; after the last path it sends
|
||||||
|
/// `ResolveFinished`. When the op's cancellation flag is set, the
|
||||||
|
/// forwarder drops the chunk receiver instead — the provider's next
|
||||||
|
/// send fails and the fetch stops. Queue state is never touched here:
|
||||||
|
/// mutations happen only when the loop processes the forwarded
|
||||||
|
/// commands. An immediate `Queue` broadcast (unchanged tracks,
|
||||||
|
/// `resolving = true`) gives clients instant feedback.
|
||||||
|
fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) {
|
||||||
|
let op = PendingResolve::new(kind);
|
||||||
|
let cancelled = op.cancel_flag();
|
||||||
|
let op_id = self.next_op_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
{
|
||||||
|
let Ok(mut pending) = self.pending.lock() else {
|
||||||
|
error!("pending ops lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
pending.insert(op_id, op);
|
||||||
|
}
|
||||||
|
debug!(op_id, ?paths, "starting queue resolve");
|
||||||
|
{
|
||||||
|
let Ok(queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Instant feedback: clients see resolving = true before the
|
||||||
|
// first chunk exists.
|
||||||
|
self.broadcast_queue(&queue);
|
||||||
|
}
|
||||||
|
let provider_tx = self.provider_tx.clone();
|
||||||
|
let playback_tx = self.playback_tx.clone();
|
||||||
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
|
for path in &paths {
|
||||||
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (chunk_tx, chunk_rx) = flume::bounded(4);
|
||||||
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
result_tx,
|
chunk_tx,
|
||||||
});
|
});
|
||||||
if let Err(err) = self.provider_tx.send_async(message).await {
|
if provider_tx.send_async(message).await.is_err() {
|
||||||
error!("provider channel closed: {err}");
|
error!("provider channel closed");
|
||||||
return all_tracks;
|
break;
|
||||||
}
|
}
|
||||||
match result_rx.recv_async().await {
|
let mut forwarded = 0usize;
|
||||||
Ok(tracks) => {
|
while let Ok(tracks) = chunk_rx.recv_async().await {
|
||||||
if tracks.is_empty() {
|
// On cancellation this loop exits and drops
|
||||||
|
// chunk_rx; the provider's next send fails and the
|
||||||
|
// fetch stops.
|
||||||
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
forwarded += tracks.len();
|
||||||
|
let apply = PlaybackCommand::ApplyResolvedChunk { op_id, tracks };
|
||||||
|
if playback_tx
|
||||||
|
.send_async(PlaybackMessage::new(apply))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
error!("playback channel closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if forwarded == 0 && !cancelled.load(Ordering::Relaxed) {
|
||||||
warn!(path, "path resolved to no playable tracks");
|
warn!(path, "path resolved to no playable tracks");
|
||||||
}
|
}
|
||||||
all_tracks.extend(tracks);
|
|
||||||
}
|
}
|
||||||
Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"),
|
// Always reported — also for cancelled or empty ops — so
|
||||||
|
// the pending map can never leak a stuck resolving flag.
|
||||||
|
let finished = PlaybackCommand::ResolveFinished { op_id };
|
||||||
|
let _ = playback_tx.send_async(PlaybackMessage::new(finished)).await;
|
||||||
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one chunk to the queue for pending op `op_id`, broadcasts
|
||||||
|
/// the grown queue, and starts playback when the chunk made a track
|
||||||
|
/// current. Chunks for an unknown op id (finished or cancelled) are
|
||||||
|
/// dropped silently.
|
||||||
|
async fn apply_resolved_chunk(&self, op_id: u64, tracks: Vec<Track>) {
|
||||||
|
let track = {
|
||||||
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let track = {
|
||||||
|
let Ok(mut pending) = self.pending.lock() else {
|
||||||
|
error!("pending ops lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(op) = pending.get_mut(&op_id) else {
|
||||||
|
trace!(op_id, "dropping chunk for a finished or cancelled op");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
op.apply_chunk(&mut queue, &tracks)
|
||||||
|
};
|
||||||
|
self.broadcast_queue(&queue);
|
||||||
|
track
|
||||||
|
};
|
||||||
|
self.play_if_some(track).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the finished op and broadcasts the final `Queue` snapshot
|
||||||
|
/// (clearing `resolving` once no ops remain). An op already removed by
|
||||||
|
/// cancellation needs no broadcast — the cancelling command mutates
|
||||||
|
/// the queue and broadcasts itself.
|
||||||
|
fn finish_resolve(&self, op_id: u64) {
|
||||||
|
let removed = {
|
||||||
|
let Ok(mut pending) = self.pending.lock() else {
|
||||||
|
error!("pending ops lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
pending.remove(&op_id)
|
||||||
|
};
|
||||||
|
let Some(op) = removed else {
|
||||||
|
trace!(op_id, "resolve finished for a cancelled op");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
debug!(op_id, tracks = op.applied(), "queue resolve finished");
|
||||||
|
let Ok(queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.broadcast_queue(&queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels every in-flight resolve op (used by `Replace` and `Clear`).
|
||||||
|
/// The forwarders see the flag, drop their chunk receivers (stopping
|
||||||
|
/// the fetches) and still report `ResolveFinished`, which is dropped as
|
||||||
|
/// unknown here.
|
||||||
|
fn cancel_pending_resolves(&self) {
|
||||||
|
let Ok(mut pending) = self.pending.lock() else {
|
||||||
|
error!("pending ops lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (op_id, op) in pending.drain() {
|
||||||
|
debug!(op_id, "cancelling in-flight resolve");
|
||||||
|
op.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trace!(count = all_tracks.len(), "resolved tracks");
|
|
||||||
all_tracks
|
/// A wire snapshot of the queue with the `resolving` flag set from the
|
||||||
|
/// pending-op map. Callers must not hold the `pending` lock (`queue` is
|
||||||
|
/// fine — the lock order is queue, then pending).
|
||||||
|
fn queue_snapshot(&self, queue: &QueueManager) -> ProtoQueue {
|
||||||
|
let resolving = self
|
||||||
|
.pending
|
||||||
|
.lock()
|
||||||
|
.map(|pending| !pending.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let mut snapshot: ProtoQueue = queue.clone().into();
|
||||||
|
snapshot.resolving = resolving;
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcasts the current queue snapshot. All queue broadcasts go
|
||||||
|
/// through here so the `resolving` flag can never be forgotten — and
|
||||||
|
/// every queue-content change reaches the persister the same way.
|
||||||
|
fn broadcast_queue(&self, queue: &QueueManager) {
|
||||||
|
self.send_persist_snapshot(queue);
|
||||||
|
self.broadcast(StreamUpdate::Queue(self.queue_snapshot(queue)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the queue's persistable state to the persister task; a no-op
|
||||||
|
/// when persistence is disabled. Latest snapshot wins, so calling this
|
||||||
|
/// on every mutation is free of backpressure (the persister skips
|
||||||
|
/// writes for unchanged snapshots).
|
||||||
|
fn send_persist_snapshot(&self, queue: &QueueManager) {
|
||||||
|
if self.store.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let proto: ProtoQueue = queue.clone().into();
|
||||||
|
self.persist_tx.send_replace(Some(QueueSnapshot {
|
||||||
|
tracks: proto.tracks,
|
||||||
|
current_position: proto.current_position,
|
||||||
|
repeat: queue.repeat,
|
||||||
|
shuffle: queue.shuffle,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
|
@ -428,43 +663,76 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts playback of the given track. When fetching stream URLs fails
|
/// Finds the stream URLs of the first playable track, starting at
|
||||||
/// the failing track is skipped and playback continues with the next
|
/// `track` and advancing the queue past unplayable ones. Tracks marked
|
||||||
/// track in the queue.
|
/// `is_skipped` (captures recorded their source as uncapturable) are
|
||||||
|
/// skipped without a provider round trip; tracks whose stream URLs
|
||||||
|
/// fail to resolve are skipped with a warning. Bounded by the queue
|
||||||
|
/// length at entry — one full pass at most — so an all-skipped queue
|
||||||
|
/// with repeat on returns `None` instead of spinning
|
||||||
|
/// (architecture/incremental-captures.md D3).
|
||||||
|
async fn next_playable_urls(&self, mut track: Track) -> Option<Vec<String>> {
|
||||||
|
let mut attempts_left = {
|
||||||
|
let Ok(queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
queue.len()
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let path = track.path.as_str();
|
||||||
|
if track.is_skipped {
|
||||||
|
debug!(path, "track is marked skipped, skipping");
|
||||||
|
} else {
|
||||||
|
match self.get_urls_for_track(path).await {
|
||||||
|
Ok(urls) if !urls.is_empty() => return Some(urls),
|
||||||
|
Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
|
||||||
|
Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attempts_left = attempts_left.saturating_sub(1);
|
||||||
|
if attempts_left == 0 {
|
||||||
|
warn!("no playable track in the queue after a full pass");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let next = {
|
||||||
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
queue.next_track()
|
||||||
|
};
|
||||||
|
match next {
|
||||||
|
Some(next_track) => track = next_track,
|
||||||
|
None => {
|
||||||
|
debug!("reached the end of the queue without a playable track");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts playback of the given track, skipping past unplayable ones
|
||||||
|
/// (see [`Self::next_playable_urls`]); stops the player when nothing
|
||||||
|
/// in the queue is playable.
|
||||||
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
||||||
async fn play(&self, track: Option<Track>) {
|
async fn play(&self, track: Option<Track>) {
|
||||||
let Some(track) = track else {
|
let Some(track) = track else {
|
||||||
debug!("nothing to play");
|
debug!("nothing to play");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut path = track.path.clone();
|
let Some(urls) = self.next_playable_urls(track).await else {
|
||||||
let urls = loop {
|
|
||||||
match self.get_urls_for_track(&path).await {
|
|
||||||
Ok(urls) if !urls.is_empty() => break urls,
|
|
||||||
Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
|
|
||||||
Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
|
|
||||||
}
|
|
||||||
let next = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
|
||||||
error!("queue lock poisoned");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
queue.next_track()
|
|
||||||
};
|
|
||||||
match next {
|
|
||||||
Some(next_track) => path = next_track.path.clone(),
|
|
||||||
None => {
|
|
||||||
error!("no playable track left in queue, stopping");
|
|
||||||
self.stop_player().await;
|
self.stop_player().await;
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let Ok(queue) = self.queue.lock() else {
|
let Ok(queue) = self.queue.lock() else {
|
||||||
error!("queue lock poisoned");
|
error!("queue lock poisoned");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Current-track moves (Next/Prev/SetCurrent/skips) change the
|
||||||
|
// persisted position without a queue broadcast.
|
||||||
|
self.send_persist_snapshot(&queue);
|
||||||
self.broadcast(StreamUpdate::QueueTrack(QueueTrack {
|
self.broadcast(StreamUpdate::QueueTrack(QueueTrack {
|
||||||
queue_position: queue.current_position() as u32,
|
queue_position: queue.current_position() as u32,
|
||||||
track: queue.current_track(),
|
track: queue.current_track(),
|
||||||
|
|
@ -476,3 +744,177 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn track(i: usize) -> Track {
|
||||||
|
Track {
|
||||||
|
path: format!("/tidal/playlists/p/{i}"),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: format!("track {i}"),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
is_skipped: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_in(dir: &TempDir) -> Arc<QueueStore> {
|
||||||
|
Arc::new(
|
||||||
|
QueueStore::open(dir.path().join("queues"))
|
||||||
|
.await
|
||||||
|
.expect("open store"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_with(store: Option<Arc<QueueStore>>) -> Playback {
|
||||||
|
let (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
|
let (provider_tx, _provider_rx) = flume::bounded(16);
|
||||||
|
Playback::new(update_tx, provider_tx, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_queue(playback: &Playback, n: usize) {
|
||||||
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
|
let _ = queue.replace_with_tracks(&tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restore_fills_the_queue_without_starting_playback() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = store_in(&dir).await;
|
||||||
|
store
|
||||||
|
.persist_current(&QueueSnapshot {
|
||||||
|
tracks: (0..3).map(track).collect(),
|
||||||
|
current_position: 1,
|
||||||
|
repeat: true,
|
||||||
|
shuffle: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("persist");
|
||||||
|
|
||||||
|
let playback = playback_with(Some(store));
|
||||||
|
playback.restore_current().await;
|
||||||
|
|
||||||
|
let queue = playback.queue.lock().expect("queue lock");
|
||||||
|
let snapshot: ProtoQueue = queue.clone().into();
|
||||||
|
assert_eq!(snapshot.tracks.len(), 3);
|
||||||
|
assert_eq!(queue.current_position(), 1);
|
||||||
|
assert!(queue.repeat);
|
||||||
|
// A restarted server stays silent: restoring must not play.
|
||||||
|
assert_eq!(
|
||||||
|
*playback.state.lock().expect("state lock"),
|
||||||
|
PlayState::Stopped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restore_survives_an_out_of_range_position() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = store_in(&dir).await;
|
||||||
|
store
|
||||||
|
.persist_current(&QueueSnapshot {
|
||||||
|
tracks: vec![track(0)],
|
||||||
|
current_position: 99, // hand-edited folder
|
||||||
|
repeat: false,
|
||||||
|
shuffle: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("persist");
|
||||||
|
let playback = playback_with(Some(store));
|
||||||
|
playback.restore_current().await;
|
||||||
|
let queue = playback.queue.lock().expect("queue lock");
|
||||||
|
assert_eq!(queue.current_position(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_queue_command_snapshots_the_live_queue() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = store_in(&dir).await;
|
||||||
|
let playback = playback_with(Some(Arc::clone(&store)));
|
||||||
|
fill_queue(&playback, 2);
|
||||||
|
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::SaveQueue {
|
||||||
|
name: "road trip".to_string(),
|
||||||
|
result_tx,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
result_rx
|
||||||
|
.recv_async()
|
||||||
|
.await
|
||||||
|
.expect("reply")
|
||||||
|
.expect("save succeeds");
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(store.dir().join("road trip"))
|
||||||
|
.expect("saved queue folder")
|
||||||
|
.filter(|e| {
|
||||||
|
!e.as_ref()
|
||||||
|
.expect("entry")
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with('.')
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(entries, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_queue_rejects_an_empty_queue() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let playback = playback_with(Some(store_in(&dir).await));
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::SaveQueue {
|
||||||
|
name: "empty".to_string(),
|
||||||
|
result_tx,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let result = result_rx.recv_async().await.expect("reply");
|
||||||
|
assert!(matches!(result, Err(SaveQueueError::EmptyQueue)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skipped_tracks_are_skipped_with_a_bounded_pass() {
|
||||||
|
// All tracks are marked skipped and repeat is on: `next_track`
|
||||||
|
// cycles forever, so only the one-full-pass bound ends the loop
|
||||||
|
// with `None`. The marked tracks are skipped without any provider
|
||||||
|
// round trip (the provider channel is closed — a call would fail,
|
||||||
|
// not hang).
|
||||||
|
let playback = playback_with(None);
|
||||||
|
let tracks: Vec<Track> = (0..3)
|
||||||
|
.map(|i| Track {
|
||||||
|
is_skipped: true,
|
||||||
|
..track(i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let first = {
|
||||||
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
|
let first = queue.replace_with_tracks(&tracks);
|
||||||
|
queue.repeat = true;
|
||||||
|
first
|
||||||
|
};
|
||||||
|
let urls = playback
|
||||||
|
.next_playable_urls(first.expect("first track"))
|
||||||
|
.await;
|
||||||
|
assert!(urls.is_none(), "an all-skipped queue has nothing playable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_mutations_reach_the_persist_channel() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let playback = playback_with(Some(store_in(&dir).await));
|
||||||
|
fill_queue(&playback, 2);
|
||||||
|
let rx = playback.persist_tx.subscribe();
|
||||||
|
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::Remove { positions: vec![1] })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let snapshot = rx.borrow().clone().expect("snapshot sent");
|
||||||
|
assert_eq!(snapshot.tracks.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
use crate::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
||||||
|
use crate::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
|
||||||
|
use crate::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
||||||
use crate::{ProviderCommand, ProviderMessage};
|
use crate::{ProviderCommand, ProviderMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crabidy_core::{
|
use crabidy_core::{
|
||||||
|
|
@ -12,21 +15,121 @@ pub struct ProviderOrchestrator {
|
||||||
pub provider_tx: flume::Sender<ProviderMessage>,
|
pub provider_tx: flume::Sender<ProviderMessage>,
|
||||||
provider_rx: flume::Receiver<ProviderMessage>,
|
provider_rx: flume::Receiver<ProviderMessage>,
|
||||||
tidal_client: Arc<tidaldy::Client>,
|
tidal_client: Arc<tidaldy::Client>,
|
||||||
|
/// `None` when the filesystem provider failed to initialize — the
|
||||||
|
/// server runs without `/fs` instead of dying (architecture D5).
|
||||||
|
fs_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// Second `fsdy` instance over the persisted-queues folder, mounted at
|
||||||
|
/// `/queues` (architecture/queue-persistence.md D1). `None` without a
|
||||||
|
/// config directory — the server then runs without `/queues`.
|
||||||
|
queues_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// Third `fsdy` instance over the bookmarks folder, mounted at
|
||||||
|
/// `/bookmarks` (architecture/bookmarks.md D1). `None` without a
|
||||||
|
/// config directory.
|
||||||
|
bookmarks_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// The bookmark writer; `None` disables link captures (and the
|
||||||
|
/// `/bookmarks` mount goes with it).
|
||||||
|
bookmark_store: Option<Arc<BookmarkStore>>,
|
||||||
|
/// Fourth `fsdy` instance over the captures folder, mounted at
|
||||||
|
/// `/captures` (architecture/captures.md D1). `None` without a config
|
||||||
|
/// directory.
|
||||||
|
captures_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// The download-capture writer; `None` disables download captures
|
||||||
|
/// (and the `/captures` mount goes with it).
|
||||||
|
capture_store: Option<Arc<CaptureStore>>,
|
||||||
|
/// The YouTube provider (yt-dlp backed); `None` when the binary
|
||||||
|
/// probe failed at init (architecture/youtube-provider.md D2).
|
||||||
|
youtube_client: Option<Arc<ytdy::Client>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the filesystem provider.
|
||||||
|
fn fs_owns(path: &str) -> bool {
|
||||||
|
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the persisted-queues provider instance.
|
||||||
|
fn queues_owns(path: &str) -> bool {
|
||||||
|
// The literal prefix mirrors `fs_owns`; QUEUES_PROVIDER_ROOT is "/queues".
|
||||||
|
path == QUEUES_PROVIDER_ROOT || path.starts_with("/queues/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the bookmarks provider instance.
|
||||||
|
fn bookmarks_owns(path: &str) -> bool {
|
||||||
|
path == BOOKMARKS_PROVIDER_ROOT || path.starts_with("/bookmarks/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the captures provider instance.
|
||||||
|
fn captures_owns(path: &str) -> bool {
|
||||||
|
path == CAPTURES_PROVIDER_ROOT || path.starts_with("/captures/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the YouTube provider.
|
||||||
|
fn youtube_owns(path: &str) -> bool {
|
||||||
|
path == ytdy::PROVIDER_ROOT || path.starts_with("/youtube/")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderOrchestrator {
|
impl ProviderOrchestrator {
|
||||||
|
/// The fs client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// provider is disabled — a `/fs` path then has no owner.
|
||||||
|
fn fs_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.fs_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("filesystem provider is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The queues client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// instance is disabled — a `/queues` path then has no owner.
|
||||||
|
fn queues_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.queues_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("queues library is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bookmarks client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// instance is disabled — a `/bookmarks` path then has no owner.
|
||||||
|
fn bookmarks_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.bookmarks_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("bookmarks library is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The captures client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// instance is disabled — a `/captures` path then has no owner.
|
||||||
|
fn captures_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.captures_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("captures library is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The YouTube client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// provider is disabled — a `/youtube` path then has no owner.
|
||||||
|
fn youtube_provider(&self) -> Result<&ytdy::Client, ProviderError> {
|
||||||
|
self.youtube_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("youtube provider is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(ProviderMessage { span, command }) = self.provider_rx.recv_async().await {
|
// Behind an Arc so long-running resolves can be spawned onto
|
||||||
|
// their own tasks while the loop keeps serving commands.
|
||||||
|
let this = Arc::new(self);
|
||||||
|
while let Ok(ProviderMessage { span, command }) = this.provider_rx.recv_async().await {
|
||||||
let handler_span =
|
let handler_span =
|
||||||
debug_span!(parent: &span, "provider_command", command = command.name());
|
debug_span!(parent: &span, "provider_command", command = command.name());
|
||||||
self.handle_command(command).instrument(handler_span).await;
|
Arc::clone(&this)
|
||||||
|
.handle_command(command)
|
||||||
|
.instrument(handler_span)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
warn!("provider message channel closed, loop exiting");
|
warn!("provider message channel closed, loop exiting");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_command(&self, command: ProviderCommand) {
|
async fn handle_command(self: Arc<Self>, command: ProviderCommand) {
|
||||||
match command {
|
match command {
|
||||||
ProviderCommand::GetLibraryNode { path, result_tx } => {
|
ProviderCommand::GetLibraryNode { path, result_tx } => {
|
||||||
let result = self.get_lib_node(&path).await;
|
let result = self.get_lib_node(&path).await;
|
||||||
|
|
@ -40,46 +143,108 @@ impl ProviderOrchestrator {
|
||||||
error!("failed to send get_track_urls result: {err}");
|
error!("failed to send get_track_urls result: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProviderCommand::ResolveTracks { path, result_tx } => {
|
ProviderCommand::ResolveTracks { path, chunk_tx } => {
|
||||||
let result = self.resolve_tracks(&path).await;
|
// Spawned: a large resolve must not block this loop, or the
|
||||||
|
// playback side deadlocks waiting for `GetTrackUrls` while
|
||||||
|
// chunks back up. Dropping `chunk_tx` at the end of the
|
||||||
|
// task is the completion signal; there is no reply channel.
|
||||||
|
let this = Arc::clone(&self);
|
||||||
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
|
if let Err(err) = this.resolve_tracks_into(&path, chunk_tx).await {
|
||||||
|
warn!(path, "resolve produced no tracks: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ProviderCommand::CreateLibraryNode {
|
||||||
|
parent_path,
|
||||||
|
title,
|
||||||
|
result_tx,
|
||||||
|
} => {
|
||||||
|
let result = self.create_lib_node(&parent_path, &title).await;
|
||||||
if let Err(err) = result_tx.send_async(result).await {
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
error!("failed to send resolve_tracks result: {err}");
|
error!("failed to send create_library_node result: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProviderCommand::RenameLibraryNode {
|
||||||
|
path,
|
||||||
|
new_title,
|
||||||
|
result_tx,
|
||||||
|
} => {
|
||||||
|
let result = self.rename_lib_node(&path, &new_title).await;
|
||||||
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
|
error!("failed to send rename_library_node result: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProviderCommand::DeleteLibraryNode { path, result_tx } => {
|
||||||
/// Resolves a path into playable tracks. A track path resolves to that
|
let result = self.delete_lib_node(&path).await;
|
||||||
/// single track; a node path is flattened by walking its queueable
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
/// descendants.
|
error!("failed to send delete_library_node result: {err}");
|
||||||
#[instrument(skip(self))]
|
}
|
||||||
async fn resolve_tracks(&self, path: &str) -> Vec<Track> {
|
}
|
||||||
if self.is_track_path(path) {
|
ProviderCommand::CaptureLibraryNode {
|
||||||
return match self.get_metadata_for_track(path).await {
|
path,
|
||||||
Ok(track) => vec![track],
|
name,
|
||||||
Err(err) => {
|
download,
|
||||||
warn!(path, "failed to resolve track: {err}");
|
progress_tx,
|
||||||
Vec::new()
|
result_tx,
|
||||||
|
} => {
|
||||||
|
// Spawned: capturing a large artist walks many provider
|
||||||
|
// nodes (and, for downloads, streams audio) and must not
|
||||||
|
// block this loop (the walk itself calls back into
|
||||||
|
// `get_lib_node` via `this`). Accept-then-stream
|
||||||
|
// (architecture/incremental-captures.md D4): validation
|
||||||
|
// answers the RPC, the walk reports through progress
|
||||||
|
// events, ending in exactly one terminal event.
|
||||||
|
let this = Arc::clone(&self);
|
||||||
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
|
let accepted = if download {
|
||||||
|
match &this.capture_store {
|
||||||
|
Some(store) => store.validate(&*this, &path, &name).await,
|
||||||
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match &this.bookmark_store {
|
||||||
|
Some(store) => store.validate(&name),
|
||||||
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if let Err(err) = accepted {
|
||||||
|
warn!(path, name, download, "capture rejected: {err}");
|
||||||
|
if let Err(err) = result_tx.send_async(Err(err)).await {
|
||||||
|
error!("failed to send capture_library_node result: {err}");
|
||||||
}
|
}
|
||||||
let mut tracks = Vec::new();
|
return;
|
||||||
let mut nodes_to_go = vec![path.to_string()];
|
}
|
||||||
while let Some(node_path) = nodes_to_go.pop() {
|
if let Err(err) = result_tx.send_async(Ok(())).await {
|
||||||
let node = match self.get_lib_node(&node_path).await {
|
error!("failed to send capture_library_node result: {err}");
|
||||||
Ok(node) => node,
|
}
|
||||||
Err(err) => {
|
let progress = crate::capture::Progress::new(&name, download, progress_tx);
|
||||||
warn!(node = node_path, "skipping unreadable node: {err}");
|
let result = if download {
|
||||||
continue;
|
match &this.capture_store {
|
||||||
|
Some(store) => store.capture(&*this, &path, &name, &progress).await,
|
||||||
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match &this.bookmark_store {
|
||||||
|
Some(store) => store.capture(&*this, &path, &name, &progress).await,
|
||||||
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if node.is_queable {
|
if let Err(err) = &result {
|
||||||
tracks.extend(node.tracks);
|
warn!(path, name, download, "cannot capture subtree: {err}");
|
||||||
nodes_to_go.extend(node.children.into_iter().map(|c| c.path))
|
}
|
||||||
|
progress
|
||||||
|
.finish(result.err().map(|err| err.to_string()))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug!(count = tracks.len(), "resolved path into tracks");
|
|
||||||
tracks
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,11 +276,132 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
||||||
error!("failed to write tidal config file: {err}");
|
error!("failed to write tidal config file: {err}");
|
||||||
};
|
};
|
||||||
|
// The filesystem provider is optional: a broken local config only
|
||||||
|
// costs the `/fs` subtree, never the server.
|
||||||
|
let fs_config_file = config_dir.join("fsdy.toml");
|
||||||
|
debug!(config_file = %fs_config_file.display(), "loading fs config");
|
||||||
|
let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
|
||||||
|
let fs_client = match fsdy::Client::init(&raw_fs_settings).await {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await {
|
||||||
|
error!("failed to write fsdy config file: {err}");
|
||||||
|
}
|
||||||
|
Some(Arc::new(client))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("filesystem provider disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The queues instance mounts the folder the playback side persists
|
||||||
|
// into; a folder that does not exist yet lists as empty-on-arrival
|
||||||
|
// (created by QueueStore::open in main). Saved queues are renamable
|
||||||
|
// and deletable; the auto-persisted `current` stays untouchable.
|
||||||
|
let queues_client = match crate::queue_store::queues_dir() {
|
||||||
|
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
||||||
|
Ok(client) => Some(Arc::new(
|
||||||
|
client
|
||||||
|
.with_editable_top_level(&[CURRENT_QUEUE_NAME])
|
||||||
|
.with_downloadable_nodes(),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("queues library disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("queues library disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Bookmarks: the orchestrator owns the store (it is the capture
|
||||||
|
// writer) and mounts the same folder read-only. Non-fatal like the
|
||||||
|
// other local providers.
|
||||||
|
let bookmark_store = match crate::bookmark_store::bookmarks_dir() {
|
||||||
|
Some(dir) => match BookmarkStore::open(dir).await {
|
||||||
|
Ok(store) => Some(Arc::new(store)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("bookmarks disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("bookmarks disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bookmarks_client = bookmark_store.as_ref().and_then(|store| {
|
||||||
|
match fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf()) {
|
||||||
|
Ok(client) => Some(Arc::new(
|
||||||
|
client
|
||||||
|
.with_editable_top_level(&[])
|
||||||
|
.with_downloadable_nodes(),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("bookmarks library disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Captures: like bookmarks, but the store downloads every track's
|
||||||
|
// audio next to its toml (architecture/captures.md D1). Non-fatal.
|
||||||
|
let capture_store = match crate::capture_store::captures_dir() {
|
||||||
|
Some(dir) => match CaptureStore::open(dir).await {
|
||||||
|
Ok(store) => Some(Arc::new(store)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("captures disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("captures disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let captures_client = capture_store.as_ref().and_then(|store| {
|
||||||
|
match fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf()) {
|
||||||
|
// The whole tree is deletable: stale downloads are
|
||||||
|
// reclaimed through the library, folders recursively and
|
||||||
|
// tracks together with their audio. Clients confirm these
|
||||||
|
// deletes (architecture/capture-deletion.md).
|
||||||
|
Ok(client) => Some(Arc::new(
|
||||||
|
client.with_editable_top_level(&[]).with_deletable_tree(),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("captures library disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// YouTube: non-fatal like the local providers — a missing or
|
||||||
|
// broken yt-dlp binary only costs the `/youtube` subtree.
|
||||||
|
let yt_config_file = config_dir.join("ytdy.toml");
|
||||||
|
debug!(config_file = %yt_config_file.display(), "loading youtube config");
|
||||||
|
let raw_yt_settings = fs::read_to_string(&yt_config_file).unwrap_or_default();
|
||||||
|
let youtube_client = match ytdy::Client::init(&raw_yt_settings).await {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(err) = tokio::fs::write(&yt_config_file, client.settings()).await {
|
||||||
|
error!("failed to write ytdy config file: {err}");
|
||||||
|
}
|
||||||
|
Some(Arc::new(client))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("youtube provider disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
let (provider_tx, provider_rx) = flume::bounded(100);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
provider_rx,
|
provider_rx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
tidal_client,
|
tidal_client,
|
||||||
|
fs_client,
|
||||||
|
queues_client,
|
||||||
|
bookmarks_client,
|
||||||
|
bookmark_store,
|
||||||
|
captures_client,
|
||||||
|
capture_store,
|
||||||
|
youtube_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +414,36 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == "/tidal" || path.starts_with("/tidal/") {
|
if path == "/tidal" || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.is_track_path(path);
|
return self.tidal_client.is_track_path(path);
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self
|
||||||
|
.fs_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|fs| fs.is_track_path(path));
|
||||||
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self
|
||||||
|
.queues_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|queues| queues.is_track_path(path));
|
||||||
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|bookmarks| bookmarks.is_track_path(path));
|
||||||
|
}
|
||||||
|
if captures_owns(path) {
|
||||||
|
return self
|
||||||
|
.captures_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|captures| captures.is_track_path(path));
|
||||||
|
}
|
||||||
|
if youtube_owns(path) {
|
||||||
|
return self
|
||||||
|
.youtube_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|youtube| youtube.is_track_path(path));
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +452,30 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if track_path.starts_with("/tidal/") {
|
if track_path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_urls_for_track(track_path).await;
|
return self.tidal_client.get_urls_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(track_path) {
|
||||||
|
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
||||||
|
}
|
||||||
|
if queues_owns(track_path) {
|
||||||
|
return self.queues_provider()?.get_urls_for_track(track_path).await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.get_urls_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if captures_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.captures_provider()?
|
||||||
|
.get_urls_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if youtube_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.youtube_provider()?
|
||||||
|
.get_urls_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +485,33 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if track_path.starts_with("/tidal/") {
|
if track_path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_metadata_for_track(track_path).await;
|
return self.tidal_client.get_metadata_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(track_path) {
|
||||||
|
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
||||||
|
}
|
||||||
|
if queues_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if captures_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.captures_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if youtube_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.youtube_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +521,37 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
let child =
|
let child =
|
||||||
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
|
if self.fs_client.is_some() {
|
||||||
|
let child =
|
||||||
|
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
|
if self.queues_client.is_some() {
|
||||||
|
let child =
|
||||||
|
LibraryNodeChild::new(QUEUES_PROVIDER_ROOT.to_owned(), "queues".to_owned(), false);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
|
if self.bookmarks_client.is_some() {
|
||||||
|
let child = LibraryNodeChild::new(
|
||||||
|
BOOKMARKS_PROVIDER_ROOT.to_owned(),
|
||||||
|
"bookmarks".to_owned(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
|
if self.captures_client.is_some() {
|
||||||
|
let child = LibraryNodeChild::new(
|
||||||
|
CAPTURES_PROVIDER_ROOT.to_owned(),
|
||||||
|
"captures".to_owned(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
|
if self.youtube_client.is_some() {
|
||||||
|
let child =
|
||||||
|
LibraryNodeChild::new(ytdy::PROVIDER_ROOT.to_owned(), "youtube".to_owned(), false);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
root_node
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +564,180 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_lib_node(path).await;
|
return self.tidal_client.get_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self.fs_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self.queues_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self.bookmarks_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if captures_owns(path) {
|
||||||
|
return self.captures_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if youtube_owns(path) {
|
||||||
|
return self.youtube_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes to the provider that owns the parent path. The synthetic root
|
||||||
|
/// itself is not creatable.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.create_lib_node(parent_path, title).await;
|
||||||
|
}
|
||||||
|
if fs_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.fs_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if queues_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if captures_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.captures_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if youtube_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.youtube_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes to the provider that owns the path. The synthetic root's own
|
||||||
|
/// children are fixed and never editable.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn rename_lib_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.rename_lib_node(path, new_title).await;
|
||||||
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
||||||
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if captures_owns(path) {
|
||||||
|
return self
|
||||||
|
.captures_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if youtube_owns(path) {
|
||||||
|
return self
|
||||||
|
.youtube_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
warn!(path, "no provider supports renaming this node");
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes to the provider that owns the path. The synthetic root is not
|
||||||
|
/// queueable, so only provider-owned paths can resolve.
|
||||||
|
#[instrument(skip(self, chunk_tx))]
|
||||||
|
async fn resolve_tracks_into(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
chunk_tx: flume::Sender<Vec<Track>>,
|
||||||
|
) -> Result<(), ProviderError> {
|
||||||
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.resolve_tracks_into(path, chunk_tx).await;
|
||||||
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self
|
||||||
|
.fs_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self
|
||||||
|
.bookmarks_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if captures_owns(path) {
|
||||||
|
return self
|
||||||
|
.captures_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if youtube_owns(path) {
|
||||||
|
return self
|
||||||
|
.youtube_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
warn!(path, "no provider owns this path");
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes to the provider that owns the path. The synthetic root's own
|
||||||
|
/// children are fixed and never deletable.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if fs_owns(path) {
|
||||||
|
return self.fs_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self.queues_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if bookmarks_owns(path) {
|
||||||
|
return self.bookmarks_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if captures_owns(path) {
|
||||||
|
return self.captures_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
|
if youtube_owns(path) {
|
||||||
|
return self.youtube_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
|
warn!(path, "no provider supports deleting this node");
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
//! Persisted queues on disk (see `architecture/queue-persistence.md`).
|
||||||
|
//!
|
||||||
|
//! Every queue is a folder under the store directory
|
||||||
|
//! (`<config>/crabidy/queues/`) holding one order-prefixed
|
||||||
|
//! `*.cbd-track.toml` **link** file per entry, plus a hidden
|
||||||
|
//! [`STATE_FILE_NAME`] sidecar. The automatically maintained queue lives in
|
||||||
|
//! [`CURRENT_QUEUE_NAME`]; every other folder is a named save. The same
|
||||||
|
//! directory is mounted read-only into the library as `/queues` by a second
|
||||||
|
//! `fsdy` instance — this module is the only writer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crabidy_core::proto::crabidy::Track;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// The library mount point of the queues directory (second `fsdy`
|
||||||
|
/// instance, see `architecture/queue-persistence.md` D1).
|
||||||
|
pub const QUEUES_PROVIDER_ROOT: &str = "/queues";
|
||||||
|
|
||||||
|
/// Reserved folder name of the automatically maintained queue.
|
||||||
|
pub const CURRENT_QUEUE_NAME: &str = "current";
|
||||||
|
|
||||||
|
/// Hidden per-queue sidecar carrying [`QueueState`]. Dot-prefixed, so
|
||||||
|
/// library listings never show it.
|
||||||
|
pub const STATE_FILE_NAME: &str = ".queue-state.toml";
|
||||||
|
|
||||||
|
/// The queues directory: `queues/` inside the crabidy config directory.
|
||||||
|
/// `None` when the platform has no config directory.
|
||||||
|
pub fn queues_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|d| d.join("crabidy").join("queues"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the playback loop knows about the queue that is worth
|
||||||
|
/// persisting. Sent through the persister's `watch` channel (latest wins)
|
||||||
|
/// and written by [`QueueStore`].
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct QueueSnapshot {
|
||||||
|
/// Queue entries in track order (not play order — shuffle order is
|
||||||
|
/// deliberately not persisted).
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
/// Index of the current track in `tracks`.
|
||||||
|
pub current_position: u32,
|
||||||
|
pub repeat: bool,
|
||||||
|
pub shuffle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The on-disk schema of the [`STATE_FILE_NAME`] sidecar.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct QueueState {
|
||||||
|
pub current_position: u32,
|
||||||
|
pub repeat: bool,
|
||||||
|
pub shuffle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from validating or writing a persisted queue.
|
||||||
|
///
|
||||||
|
/// At the RPC boundary: `InvalidName` → `invalid_argument`, `EmptyQueue` →
|
||||||
|
/// `failed_precondition`, the rest → `internal`. Messages carry names and
|
||||||
|
/// paths, never file contents.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SaveQueueError {
|
||||||
|
#[error("invalid queue name: {0}")]
|
||||||
|
InvalidName(&'static str),
|
||||||
|
#[error("the queue is empty")]
|
||||||
|
EmptyQueue,
|
||||||
|
#[error("queue persistence is disabled")]
|
||||||
|
Disabled,
|
||||||
|
#[error("cannot write queue: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
TrackFile(#[from] fsdy::TrackFileError),
|
||||||
|
#[error("cannot serialize queue state: {0}")]
|
||||||
|
State(#[from] toml::ser::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and writes persisted queue folders. Cheap to clone behind an
|
||||||
|
/// `Arc`; all I/O is `tokio::fs`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct QueueStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueStore {
|
||||||
|
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||||
|
/// missing.
|
||||||
|
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
Ok(Self { dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store directory (what the `/queues` provider instance mounts).
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a user-supplied queue name, returning the trimmed name.
|
||||||
|
///
|
||||||
|
/// Rejected: empty after trimming, containing `/`, `\` or NUL, starting
|
||||||
|
/// with a dot (hidden folders are invisible to listings), and the
|
||||||
|
/// reserved [`CURRENT_QUEUE_NAME`].
|
||||||
|
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
|
||||||
|
// The shared fs-provider naming rules, with the auto-persisted
|
||||||
|
// queue's folder reserved.
|
||||||
|
fsdy::validate_folder_name(name, &[CURRENT_QUEUE_NAME]).map_err(SaveQueueError::InvalidName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves `snapshot` as the named queue, overwriting an existing one.
|
||||||
|
///
|
||||||
|
/// Validates `name` per [`Self::validate_name`] and rejects an empty
|
||||||
|
/// snapshot with [`SaveQueueError::EmptyQueue`]. The folder is written
|
||||||
|
/// to a hidden temp sibling first, then swapped into place (remove old,
|
||||||
|
/// rename) — a crash can lose the folder, never corrupt it half-written
|
||||||
|
/// next to intact files.
|
||||||
|
pub async fn save(&self, name: &str, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
||||||
|
let name = Self::validate_name(name)?;
|
||||||
|
if snapshot.tracks.is_empty() {
|
||||||
|
return Err(SaveQueueError::EmptyQueue);
|
||||||
|
}
|
||||||
|
self.write_queue_dir(name, snapshot).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists `snapshot` as the current queue ([`CURRENT_QUEUE_NAME`]).
|
||||||
|
///
|
||||||
|
/// Same write path as [`Self::save`] but without name validation and
|
||||||
|
/// with an empty snapshot allowed — clearing the queue must persist as
|
||||||
|
/// cleared.
|
||||||
|
pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
||||||
|
self.write_queue_dir(CURRENT_QUEUE_NAME, snapshot).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared write path: build the whole folder as a hidden temp
|
||||||
|
/// sibling, then swap it into place (remove old, rename). A crash can
|
||||||
|
/// lose the folder, never leave it half-written next to intact files
|
||||||
|
/// (architecture/queue-persistence.md D3).
|
||||||
|
async fn write_queue_dir(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
snapshot: &QueueSnapshot,
|
||||||
|
) -> Result<(), SaveQueueError> {
|
||||||
|
let tmp = self.dir.join(format!(".tmp-{name}"));
|
||||||
|
// A leftover temp folder from a crashed or racing write is stale.
|
||||||
|
if tokio::fs::try_exists(&tmp).await? {
|
||||||
|
tokio::fs::remove_dir_all(&tmp).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(&tmp).await?;
|
||||||
|
for (index, track) in snapshot.tracks.iter().enumerate() {
|
||||||
|
let text = fsdy::TrackFile::from_track(track).to_toml()?;
|
||||||
|
let file = tmp.join(fsdy::track_file_name(index, &track.title));
|
||||||
|
tokio::fs::write(file, text).await?;
|
||||||
|
}
|
||||||
|
let state = QueueState {
|
||||||
|
current_position: snapshot.current_position,
|
||||||
|
repeat: snapshot.repeat,
|
||||||
|
shuffle: snapshot.shuffle,
|
||||||
|
};
|
||||||
|
tokio::fs::write(tmp.join(STATE_FILE_NAME), toml::to_string_pretty(&state)?).await?;
|
||||||
|
|
||||||
|
let target = self.dir.join(name);
|
||||||
|
if tokio::fs::try_exists(&target).await? {
|
||||||
|
tokio::fs::remove_dir_all(&target).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&tmp, &target).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the persisted current queue for the startup restore.
|
||||||
|
///
|
||||||
|
/// Reads the folder like a library listing (sorted case-insensitively,
|
||||||
|
/// broken/hidden/foreign entries skipped with warnings) plus the
|
||||||
|
/// [`QueueState`] sidecar (missing or broken sidecar → default state).
|
||||||
|
/// `None` when the folder does not exist — a fresh start. Never fails
|
||||||
|
/// the server; every defect is a warning and degrades to less state.
|
||||||
|
pub async fn load_current(&self) -> Option<QueueSnapshot> {
|
||||||
|
let dir = self.dir.join(CURRENT_QUEUE_NAME);
|
||||||
|
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(read_dir) => read_dir,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(dir = %dir.display(), "cannot read the persisted queue: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirror the provider listing: visible regular `*.cbd-track.toml`
|
||||||
|
// files, sorted case-insensitively — restore order == listing order.
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let entry = match read_dir.next_entry().await {
|
||||||
|
Ok(Some(entry)) => entry,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(dir = %dir.display(), "error while reading the persisted queue: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let is_file = entry
|
||||||
|
.file_type()
|
||||||
|
.await
|
||||||
|
.is_ok_and(|file_type| file_type.is_file());
|
||||||
|
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||||
|
warn!(dir = %dir.display(), "skipping queue entry with non-UTF-8 name");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_file && !name.starts_with('.') && name.ends_with(fsdy::TRACK_FILE_SUFFIX) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_by_key(|name| name.to_lowercase());
|
||||||
|
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let file = dir.join(&name);
|
||||||
|
let text = match tokio::fs::read_to_string(&file).await {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(file = %file.display(), "cannot read queue entry: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match fsdy::TrackFile::parse(&text) {
|
||||||
|
Ok(track_file) => {
|
||||||
|
// The same library path the /queues listing would give
|
||||||
|
// the entry, so non-link playables behave identically.
|
||||||
|
let lib_path = crabidy_core::join_path(
|
||||||
|
&crabidy_core::join_path(QUEUES_PROVIDER_ROOT, CURRENT_QUEUE_NAME),
|
||||||
|
&crabidy_core::encode_segment(&name),
|
||||||
|
);
|
||||||
|
tracks.push(track_file.to_track(&lib_path));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(file = %file.display(), "skipping invalid queue entry: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_file = dir.join(STATE_FILE_NAME);
|
||||||
|
let state = match tokio::fs::read_to_string(&state_file).await {
|
||||||
|
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
|
||||||
|
warn!(file = %state_file.display(), "broken queue state, using defaults: {err}");
|
||||||
|
QueueState::default()
|
||||||
|
}),
|
||||||
|
Err(err) => {
|
||||||
|
debug!(file = %state_file.display(), "no queue state, using defaults: {err}");
|
||||||
|
QueueState::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(QueueSnapshot {
|
||||||
|
tracks,
|
||||||
|
current_position: state.current_position,
|
||||||
|
repeat: state.repeat,
|
||||||
|
shuffle: state.shuffle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the auto-persist task: awaits snapshot changes on `rx`, debounces
|
||||||
|
/// briefly to coalesce bursts (resolve chunks), skips writes whose snapshot
|
||||||
|
/// equals the last one written, and rewrites the current queue folder.
|
||||||
|
/// Write failures are warnings; the task never affects playback. Exits when
|
||||||
|
/// the sender side is dropped.
|
||||||
|
pub fn spawn_persister(
|
||||||
|
store: Arc<QueueStore>,
|
||||||
|
mut rx: tokio::sync::watch::Receiver<Option<QueueSnapshot>>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut last_written: Option<QueueSnapshot> = None;
|
||||||
|
while rx.changed().await.is_ok() {
|
||||||
|
// Debounce: a resolve streams many chunks in quick succession;
|
||||||
|
// the watch channel keeps only the newest snapshot, so waiting
|
||||||
|
// briefly coalesces the burst into one write.
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
let Some(snapshot) = rx.borrow_and_update().clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Broadcasts that only toggled the `resolving` flag carry an
|
||||||
|
// unchanged snapshot — skip the write.
|
||||||
|
if last_written.as_ref() == Some(&snapshot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match store.persist_current(&snapshot).await {
|
||||||
|
Ok(()) => last_written = Some(snapshot),
|
||||||
|
Err(err) => warn!("cannot persist the current queue: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!("queue snapshot channel closed, persister exiting");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crabidy_core::proto::crabidy::Album;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn track(i: usize) -> Track {
|
||||||
|
Track {
|
||||||
|
path: format!("/tidal/playlists/p/{i}"),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: format!("track {i}"),
|
||||||
|
duration: Some(60 + i as u32),
|
||||||
|
album: Some(Album {
|
||||||
|
title: "album".to_string(),
|
||||||
|
release_date: None,
|
||||||
|
}),
|
||||||
|
is_skipped: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(n: usize) -> QueueSnapshot {
|
||||||
|
QueueSnapshot {
|
||||||
|
tracks: (0..n).map(track).collect(),
|
||||||
|
current_position: 0,
|
||||||
|
repeat: false,
|
||||||
|
shuffle: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store() -> (QueueStore, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = QueueStore::open(dir.path().join("queues"))
|
||||||
|
.await
|
||||||
|
.expect("open creates the directory");
|
||||||
|
(store, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorted visible file names of a queue folder.
|
||||||
|
fn visible_files(dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||||
|
.expect("queue folder")
|
||||||
|
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| !n.starts_with('.'))
|
||||||
|
.collect();
|
||||||
|
names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_writes_ordered_link_files_and_the_sidecar() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let mut snap = snapshot(3);
|
||||||
|
// A queue may hold fs tracks; persisting must link to them too.
|
||||||
|
snap.tracks[1].path = "/fs/mix/song.cbd-track.toml".to_string();
|
||||||
|
store.save("road trip", &snap).await.expect("save");
|
||||||
|
|
||||||
|
let queue_dir = store.dir().join("road trip");
|
||||||
|
let names = visible_files(&queue_dir);
|
||||||
|
assert_eq!(names.len(), 3);
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
assert!(name.starts_with(&format!("{:04} ", i + 1)), "{name}");
|
||||||
|
let text = std::fs::read_to_string(queue_dir.join(name)).expect("read entry");
|
||||||
|
let file = fsdy::TrackFile::parse(&text).expect("entry parses");
|
||||||
|
// The listing rewrite restores the original track exactly.
|
||||||
|
assert_eq!(file.to_track("/queues/irrelevant"), snap.tracks[i]);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
queue_dir.join(STATE_FILE_NAME).exists(),
|
||||||
|
"sidecar written (hidden from listings by its dot prefix)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_validates_names_and_rejects_an_empty_queue() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
for bad in ["", " ", "a/b", "a\\b", ".hidden", CURRENT_QUEUE_NAME] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.save(bad, &snapshot(1)).await,
|
||||||
|
Err(SaveQueueError::InvalidName(_))
|
||||||
|
),
|
||||||
|
"name {bad:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
store.save("fine", &snapshot(0)).await,
|
||||||
|
Err(SaveQueueError::EmptyQueue)
|
||||||
|
));
|
||||||
|
// A valid name is used trimmed.
|
||||||
|
store
|
||||||
|
.save(" padded ", &snapshot(1))
|
||||||
|
.await
|
||||||
|
.expect("trimmed name saves");
|
||||||
|
assert!(store.dir().join("padded").is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_overwrites_an_existing_queue_completely() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store.save("mix", &snapshot(3)).await.expect("first save");
|
||||||
|
store.save("mix", &snapshot(1)).await.expect("overwrite");
|
||||||
|
// No stale entries from the longer first save survive.
|
||||||
|
assert_eq!(visible_files(&store.dir().join("mix")).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_current_and_load_round_trip() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let snap = QueueSnapshot {
|
||||||
|
current_position: 2,
|
||||||
|
repeat: true,
|
||||||
|
shuffle: true,
|
||||||
|
..snapshot(4)
|
||||||
|
};
|
||||||
|
store.persist_current(&snap).await.expect("persist");
|
||||||
|
let loaded = store.load_current().await.expect("load");
|
||||||
|
assert_eq!(loaded, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_current_accepts_an_empty_queue() {
|
||||||
|
// Clearing the queue must persist as cleared, not keep yesterday's
|
||||||
|
// tracks for the next restart.
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.persist_current(&snapshot(2))
|
||||||
|
.await
|
||||||
|
.expect("non-empty");
|
||||||
|
store.persist_current(&snapshot(0)).await.expect("empty");
|
||||||
|
let loaded = store.load_current().await.expect("load");
|
||||||
|
assert!(loaded.tracks.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_current_without_a_folder_is_a_fresh_start() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
assert!(store.load_current().await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_current_skips_broken_entries_and_survives_a_broken_sidecar() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store.persist_current(&snapshot(2)).await.expect("persist");
|
||||||
|
let current = store.dir().join(CURRENT_QUEUE_NAME);
|
||||||
|
std::fs::write(current.join("0000 broken.cbd-track.toml"), "not [ toml")
|
||||||
|
.expect("write broken entry");
|
||||||
|
std::fs::write(current.join(STATE_FILE_NAME), "also not [ toml")
|
||||||
|
.expect("break the sidecar");
|
||||||
|
let loaded = store.load_current().await.expect("load");
|
||||||
|
// The two good tracks load; the broken entry is skipped and the
|
||||||
|
// broken sidecar degrades to default state instead of failing.
|
||||||
|
assert_eq!(loaded.tracks.len(), 2);
|
||||||
|
assert_eq!(loaded.current_position, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persister_writes_the_latest_snapshot() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let store = Arc::new(store);
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||||
|
spawn_persister(Arc::clone(&store), rx);
|
||||||
|
// A burst: only the newest snapshot matters (latest-wins channel).
|
||||||
|
tx.send(Some(snapshot(5))).expect("send");
|
||||||
|
tx.send(Some(snapshot(3))).expect("send");
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
if let Some(loaded) = store.load_current().await {
|
||||||
|
if loaded.tracks.len() == 3 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"persister never wrote the latest snapshot"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
|
use crate::bookmark_store::CaptureError;
|
||||||
|
use crate::queue_store::SaveQueueError;
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
|
||||||
ClearQueueResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, GetUpdateStreamRequest,
|
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
|
||||||
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, InsertResponse, NextRequest,
|
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest,
|
||||||
NextResponse, PrevRequest, PrevResponse, QueueRequest, QueueResponse, RemoveRequest,
|
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse,
|
||||||
RemoveResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RestartTrackResponse,
|
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
||||||
SaveQueueRequest, SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest,
|
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
|
||||||
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
|
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
|
||||||
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
|
||||||
|
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SetCurrentRequest,
|
||||||
|
SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse,
|
||||||
|
TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse,
|
||||||
|
ToggleShuffleRequest, ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
|
use crabidy_core::ProviderError;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
|
@ -99,6 +106,136 @@ impl CrabidyService for RpcService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a node under a creatable parent via the provider loop.
|
||||||
|
///
|
||||||
|
/// Error mapping is part of the contract: `NotSupported` →
|
||||||
|
/// `failed_precondition`, `InvalidInput` → `invalid_argument`, everything
|
||||||
|
/// else `internal`.
|
||||||
|
#[instrument(skip(self, request), fields(parent_path, title))]
|
||||||
|
async fn create_library_node(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateLibraryNodeRequest>,
|
||||||
|
) -> Result<Response<CreateLibraryNodeResponse>, Status> {
|
||||||
|
let CreateLibraryNodeRequest { parent_path, title } = request.into_inner();
|
||||||
|
tracing::Span::current().record("parent_path", parent_path.as_str());
|
||||||
|
tracing::Span::current().record("title", title.as_str());
|
||||||
|
debug!("received create_library_node request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.provider_tx
|
||||||
|
.send_async(ProviderMessage::new(ProviderCommand::CreateLibraryNode {
|
||||||
|
parent_path,
|
||||||
|
title,
|
||||||
|
result_tx,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
Status::internal("provider unavailable")
|
||||||
|
})?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from provider: {err}");
|
||||||
|
Status::internal("provider did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(node) => Ok(Response::new(CreateLibraryNodeResponse {
|
||||||
|
node: Some(node),
|
||||||
|
})),
|
||||||
|
Err(ProviderError::NotSupported) => Err(Status::failed_precondition(
|
||||||
|
"this node does not support creating children",
|
||||||
|
)),
|
||||||
|
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node title")),
|
||||||
|
Err(err) => {
|
||||||
|
error!("create_library_node failed: {err}");
|
||||||
|
Err(Status::internal(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames an editable node via the provider loop. Same error mapping as
|
||||||
|
/// `create_library_node`: `NotSupported` → `failed_precondition`,
|
||||||
|
/// `InvalidInput` → `invalid_argument`, everything else `internal`.
|
||||||
|
#[instrument(skip(self, request), fields(path, new_title))]
|
||||||
|
async fn rename_library_node(
|
||||||
|
&self,
|
||||||
|
request: Request<RenameLibraryNodeRequest>,
|
||||||
|
) -> Result<Response<RenameLibraryNodeResponse>, Status> {
|
||||||
|
let RenameLibraryNodeRequest { path, new_title } = request.into_inner();
|
||||||
|
tracing::Span::current().record("path", path.as_str());
|
||||||
|
tracing::Span::current().record("new_title", new_title.as_str());
|
||||||
|
debug!("received rename_library_node request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.provider_tx
|
||||||
|
.send_async(ProviderMessage::new(ProviderCommand::RenameLibraryNode {
|
||||||
|
path,
|
||||||
|
new_title,
|
||||||
|
result_tx,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
Status::internal("provider unavailable")
|
||||||
|
})?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from provider: {err}");
|
||||||
|
Status::internal("provider did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(node) => Ok(Response::new(RenameLibraryNodeResponse {
|
||||||
|
node: Some(node),
|
||||||
|
})),
|
||||||
|
Err(ProviderError::NotSupported) => {
|
||||||
|
Err(Status::failed_precondition("this node cannot be renamed"))
|
||||||
|
}
|
||||||
|
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node title")),
|
||||||
|
Err(err) => {
|
||||||
|
error!("rename_library_node failed: {err}");
|
||||||
|
Err(Status::internal(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a deletable node via the provider loop and returns the
|
||||||
|
/// refreshed parent. Same error mapping as `create_library_node`.
|
||||||
|
#[instrument(skip(self, request), fields(path))]
|
||||||
|
async fn delete_library_node(
|
||||||
|
&self,
|
||||||
|
request: Request<DeleteLibraryNodeRequest>,
|
||||||
|
) -> Result<Response<DeleteLibraryNodeResponse>, Status> {
|
||||||
|
let DeleteLibraryNodeRequest { path } = request.into_inner();
|
||||||
|
tracing::Span::current().record("path", path.as_str());
|
||||||
|
debug!("received delete_library_node request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.provider_tx
|
||||||
|
.send_async(ProviderMessage::new(ProviderCommand::DeleteLibraryNode {
|
||||||
|
path,
|
||||||
|
result_tx,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
Status::internal("provider unavailable")
|
||||||
|
})?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from provider: {err}");
|
||||||
|
Status::internal("provider did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(parent) => Ok(Response::new(DeleteLibraryNodeResponse {
|
||||||
|
parent: Some(parent),
|
||||||
|
})),
|
||||||
|
Err(ProviderError::NotSupported) => {
|
||||||
|
Err(Status::failed_precondition("this node cannot be deleted"))
|
||||||
|
}
|
||||||
|
// No provider raises this for delete today; mapped anyway so the
|
||||||
|
// contract stays uniform across the node-mutation rpcs.
|
||||||
|
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node path")),
|
||||||
|
Err(err) => {
|
||||||
|
error!("delete_library_node failed: {err}");
|
||||||
|
Err(Status::internal(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(paths))]
|
#[instrument(skip(self, request), fields(paths))]
|
||||||
async fn queue(
|
async fn queue(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -239,13 +376,115 @@ impl CrabidyService for RpcService {
|
||||||
Ok(Response::new(Box::pin(output_stream)))
|
Ok(Response::new(Box::pin(output_stream)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
/// Captures a queueable subtree as a bookmark via the provider loop
|
||||||
|
/// (structure-preserving snapshot under `/bookmarks/<name>`), or —
|
||||||
|
/// with `download` — as a download capture under `/captures/<name>`
|
||||||
|
/// (incremental: an existing name is resumed, not overwritten).
|
||||||
|
///
|
||||||
|
/// Returns once the capture is **accepted**: the reply covers
|
||||||
|
/// validation only, the walk runs detached and reports through
|
||||||
|
/// `CaptureProgress` events on the update stream (forwarded here from
|
||||||
|
/// the provider's progress channel), ending in one `finished` event.
|
||||||
|
///
|
||||||
|
/// Error mapping is part of the contract: invalid name or
|
||||||
|
/// uncapturable source → `invalid_argument`; a disabled store or a
|
||||||
|
/// source that does not allow downloads → `failed_precondition`.
|
||||||
|
/// Walk/write/download failures happen after the reply and surface in
|
||||||
|
/// the terminal progress event (and the server log).
|
||||||
|
#[instrument(skip(self, request), fields(path, name, download))]
|
||||||
|
async fn capture_library_node(
|
||||||
|
&self,
|
||||||
|
request: Request<CaptureLibraryNodeRequest>,
|
||||||
|
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
||||||
|
let CaptureLibraryNodeRequest {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
download,
|
||||||
|
} = request.into_inner();
|
||||||
|
tracing::Span::current().record("path", path.as_str());
|
||||||
|
tracing::Span::current().record("name", name.as_str());
|
||||||
|
tracing::Span::current().record("download", download);
|
||||||
|
debug!("received capture_library_node request");
|
||||||
|
// The walk's progress events fan out to every connected client via
|
||||||
|
// the update broadcast; the forwarder dies with the walk's terminal
|
||||||
|
// event (the provider drops the sender).
|
||||||
|
let (progress_tx, progress_rx) = flume::bounded(64);
|
||||||
|
let update_tx = self.update_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(progress) = progress_rx.recv_async().await {
|
||||||
|
// No subscribers is normal (e.g. no client connected).
|
||||||
|
let _ = update_tx.send(StreamUpdate::CaptureProgress(progress));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.provider_tx
|
||||||
|
.send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
download,
|
||||||
|
progress_tx,
|
||||||
|
result_tx,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
Status::internal("provider unavailable")
|
||||||
|
})?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from provider: {err}");
|
||||||
|
Status::internal("provider did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
||||||
|
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
|
||||||
|
Err(Status::invalid_argument(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(
|
||||||
|
err @ (CaptureError::TooLarge(_)
|
||||||
|
| CaptureError::Disabled
|
||||||
|
| CaptureError::Unsupported),
|
||||||
|
) => Err(Status::failed_precondition(err.to_string())),
|
||||||
|
Err(err) => {
|
||||||
|
error!("capture_library_node failed: {err}");
|
||||||
|
Err(Status::internal("cannot capture the subtree"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the current queue under a name (persisted queues, visible as
|
||||||
|
/// `/queues/<name>` in the library).
|
||||||
|
///
|
||||||
|
/// Error mapping is part of the contract: an invalid name →
|
||||||
|
/// `invalid_argument`; an empty queue or disabled persistence →
|
||||||
|
/// `failed_precondition`; I/O and serialization failures → `internal`.
|
||||||
|
#[instrument(skip(self, request), fields(name))]
|
||||||
async fn save_queue(
|
async fn save_queue(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<SaveQueueRequest>,
|
request: Request<SaveQueueRequest>,
|
||||||
) -> Result<Response<SaveQueueResponse>, Status> {
|
) -> Result<Response<SaveQueueResponse>, Status> {
|
||||||
debug!("received save_queue request (not implemented)");
|
let name = request.into_inner().name;
|
||||||
Ok(Response::new(SaveQueueResponse {}))
|
tracing::Span::current().record("name", name.as_str());
|
||||||
|
debug!("received save_queue request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.send_playback(PlaybackCommand::SaveQueue { name, result_tx })
|
||||||
|
.await?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from playback loop: {err}");
|
||||||
|
Status::internal("playback loop did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
||||||
|
Err(err @ SaveQueueError::InvalidName(_)) => {
|
||||||
|
Err(Status::invalid_argument(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(err @ (SaveQueueError::EmptyQueue | SaveQueueError::Disabled)) => {
|
||||||
|
Err(Status::failed_precondition(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("save_queue failed: {err}");
|
||||||
|
Err(Status::internal("cannot save the queue"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
|
||||||
|
//!
|
||||||
|
//! Today this only carries the `[auth]` role hashes
|
||||||
|
//! (architecture/roles-auth.md). The file is optional — a missing file
|
||||||
|
//! runs the server open, exactly as before the feature — but a file
|
||||||
|
//! that exists and does not parse aborts startup: silently ignoring a
|
||||||
|
//! broken auth config would run an intended-to-be-locked server open
|
||||||
|
//! (fail-closed, quality/roles-auth.md).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// The server config file name inside the crabidy config directory.
|
||||||
|
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||||
|
|
||||||
|
/// Contents of `crabidy-server.toml`.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ServerSettings {
|
||||||
|
/// Role credentials; absent (or empty) means the server runs open.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: AuthSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One PHC password hash per role; a role without a hash cannot
|
||||||
|
/// authenticate. Generate hashes with `crabidy-server hash-password`.
|
||||||
|
/// Hashes are not passwords, but the file should stay private anyway.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AuthSettings {
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub queue_owner: Option<String>,
|
||||||
|
pub queue_appender: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthSettings {
|
||||||
|
/// Whether any role is credentialed — the switch that turns
|
||||||
|
/// authentication on for every RPC.
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerSettings {
|
||||||
|
/// Loads the settings from `config_dir`.
|
||||||
|
///
|
||||||
|
/// A missing file yields the defaults (auth disabled). An existing
|
||||||
|
/// file that cannot be read or parsed is an error — the caller
|
||||||
|
/// must abort startup rather than run open.
|
||||||
|
pub fn load(config_dir: &Path) -> Result<Self, String> {
|
||||||
|
let file = config_dir.join(SETTINGS_FILE);
|
||||||
|
let raw = match std::fs::read_to_string(&file) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
|
||||||
|
};
|
||||||
|
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_disables_auth() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("defaults");
|
||||||
|
assert!(!settings.auth.enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_load_and_enable_auth() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join(SETTINGS_FILE),
|
||||||
|
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("parse");
|
||||||
|
assert!(settings.auth.enabled());
|
||||||
|
assert_eq!(
|
||||||
|
settings.auth.queue_appender.as_deref(),
|
||||||
|
Some("$argon2id$fake")
|
||||||
|
);
|
||||||
|
assert!(settings.auth.owner.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_file_is_a_startup_error_not_an_open_server() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
for bad in [
|
||||||
|
"[auth\n",
|
||||||
|
"[auth]\nowner = 3\n",
|
||||||
|
"[auth]\nonwer = \"typo\"\n",
|
||||||
|
] {
|
||||||
|
std::fs::write(dir.path().join(SETTINGS_FILE), bad).expect("write");
|
||||||
|
let err = ServerSettings::load(dir.path()).expect_err(bad);
|
||||||
|
assert!(err.contains("crabidy-server.toml"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
//! Turns a window of audio samples into a handful of normalized
|
||||||
|
//! frequency bars (architecture/spectrum.md). Pure DSP — the tap
|
||||||
|
//! (`audio-player`) supplies samples, the stream carries the result;
|
||||||
|
//! this only does the maths.
|
||||||
|
|
||||||
|
use realfft::num_complex::Complex;
|
||||||
|
use realfft::{RealFftPlanner, RealToComplex};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Number of bars the visualizer shows.
|
||||||
|
pub const SPECTRUM_BINS: usize = 24;
|
||||||
|
|
||||||
|
/// Magnitudes below this (dBFS) map to an empty bar; 0 dB maps to full.
|
||||||
|
const MIN_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
/// A reusable forward-FFT + log-bin folder for a fixed window length.
|
||||||
|
pub struct SpectrumAnalyzer {
|
||||||
|
fft: Arc<dyn RealToComplex<f32>>,
|
||||||
|
/// Hann window applied before the transform to cut spectral leakage.
|
||||||
|
window: Vec<f32>,
|
||||||
|
input: Vec<f32>,
|
||||||
|
output: Vec<Complex<f32>>,
|
||||||
|
/// `SPECTRUM_BINS + 1` boundaries into the magnitude array, spaced
|
||||||
|
/// logarithmically so the bars are roughly musically even.
|
||||||
|
edges: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpectrumAnalyzer {
|
||||||
|
pub fn new(window_len: usize) -> Self {
|
||||||
|
let fft = RealFftPlanner::<f32>::new().plan_fft_forward(window_len);
|
||||||
|
let input = fft.make_input_vec();
|
||||||
|
let output = fft.make_output_vec();
|
||||||
|
let window = (0..window_len).map(|i| hann(i, window_len)).collect();
|
||||||
|
let edges = log_bin_edges(output.len(), SPECTRUM_BINS);
|
||||||
|
Self {
|
||||||
|
fft,
|
||||||
|
window,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
edges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds `samples` (exactly the window length) into `SPECTRUM_BINS`
|
||||||
|
/// normalized `[0, 1]` magnitudes, low frequency first. A processing
|
||||||
|
/// error or a wrong-length input yields all-zero bars rather than a
|
||||||
|
/// panic.
|
||||||
|
pub fn analyze(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||||
|
if samples.len() != self.input.len() {
|
||||||
|
return vec![0.0; SPECTRUM_BINS];
|
||||||
|
}
|
||||||
|
for (dst, (sample, w)) in self
|
||||||
|
.input
|
||||||
|
.iter_mut()
|
||||||
|
.zip(samples.iter().zip(self.window.iter()))
|
||||||
|
{
|
||||||
|
*dst = sample * w;
|
||||||
|
}
|
||||||
|
if self.fft.process(&mut self.input, &mut self.output).is_err() {
|
||||||
|
return vec![0.0; SPECTRUM_BINS];
|
||||||
|
}
|
||||||
|
let n = self.window.len() as f32;
|
||||||
|
// Single-sided amplitude (2/N): a full-scale tone reads near 1.0
|
||||||
|
// at its bin, so the bars fill for ordinary listening levels.
|
||||||
|
let magnitude = |c: &Complex<f32>| c.norm() * 2.0 / n;
|
||||||
|
(0..SPECTRUM_BINS)
|
||||||
|
.map(|bar| {
|
||||||
|
let lo = self.edges[bar];
|
||||||
|
let hi = self.edges[bar + 1].max(lo + 1).min(self.output.len());
|
||||||
|
// Peak within the band, not the mean: a strong component
|
||||||
|
// must light its bar instead of being diluted by the
|
||||||
|
// quiet bins around it (which averaging does).
|
||||||
|
let peak = self.output[lo..hi]
|
||||||
|
.iter()
|
||||||
|
.map(magnitude)
|
||||||
|
.fold(0.0_f32, f32::max);
|
||||||
|
// Log-compress: dBFS mapped onto [0, 1].
|
||||||
|
let db = 20.0 * (peak + 1e-9).log10();
|
||||||
|
((db - MIN_DB) / -MIN_DB).clamp(0.0, 1.0)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Hann window coefficient for sample `i` of `len`.
|
||||||
|
fn hann(i: usize, len: usize) -> f32 {
|
||||||
|
let x = std::f32::consts::PI * i as f32 / (len - 1) as f32;
|
||||||
|
x.sin().powi(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Geometrically-spaced boundaries into a magnitude array of `mag_len`
|
||||||
|
/// (skipping the DC bin at 0), giving `bins` groups. Monotonic
|
||||||
|
/// non-decreasing; callers widen any empty group to at least one bin.
|
||||||
|
fn log_bin_edges(mag_len: usize, bins: usize) -> Vec<usize> {
|
||||||
|
let lo = 1.0_f32;
|
||||||
|
let hi = (mag_len - 1).max(2) as f32;
|
||||||
|
(0..=bins)
|
||||||
|
.map(|b| {
|
||||||
|
let t = b as f32 / bins as f32;
|
||||||
|
(lo * (hi / lo).powf(t)).round() as usize
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const WINDOW: usize = 2048;
|
||||||
|
|
||||||
|
fn sine(freq: f32, sample_rate: f32, len: usize) -> Vec<f32> {
|
||||||
|
(0..len)
|
||||||
|
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silence_is_all_zero_bars() {
|
||||||
|
let mut a = SpectrumAnalyzer::new(WINDOW);
|
||||||
|
let bars = a.analyze(&vec![0.0; WINDOW]);
|
||||||
|
assert_eq!(bars.len(), SPECTRUM_BINS);
|
||||||
|
assert!(bars.iter().all(|&b| b == 0.0), "{bars:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tone_lights_one_region_and_stays_normalized() {
|
||||||
|
let mut a = SpectrumAnalyzer::new(WINDOW);
|
||||||
|
// ~1 kHz at 44.1 kHz lands in the upper-middle of the log bars.
|
||||||
|
let bars = a.analyze(&sine(1000.0, 44_100.0, WINDOW));
|
||||||
|
assert_eq!(bars.len(), SPECTRUM_BINS);
|
||||||
|
assert!(bars.iter().all(|&b| (0.0..=1.0).contains(&b)), "{bars:?}");
|
||||||
|
let peak = bars
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
peak.1 > 0.5,
|
||||||
|
"a pure tone should drive its bar high: {bars:?}"
|
||||||
|
);
|
||||||
|
// Energy is concentrated: most bars stay well below the peak.
|
||||||
|
let loud = bars.iter().filter(|&&b| b > peak.1 * 0.5).count();
|
||||||
|
assert!(
|
||||||
|
loud <= 4,
|
||||||
|
"a tone should not light the whole spectrum: {bars:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_length_input_is_zero_not_a_panic() {
|
||||||
|
let mut a = SpectrumAnalyzer::new(WINDOW);
|
||||||
|
assert!(a.analyze(&[0.1, 0.2, 0.3]).iter().all(|&b| b == 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bin_edges_are_monotonic() {
|
||||||
|
let edges = log_bin_edges(WINDOW / 2 + 1, SPECTRUM_BINS);
|
||||||
|
assert_eq!(edges.len(), SPECTRUM_BINS + 1);
|
||||||
|
assert!(edges.windows(2).all(|w| w[1] >= w[0]), "{edges:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
//! Serves the embedded web client (feature `web-ui`,
|
||||||
|
//! architecture/web-client.md): the trunk bundle staged by `build.rs`
|
||||||
|
//! ships inside the binary and answers every request the gRPC route
|
||||||
|
//! did not claim. Assets are public by design — the app shell is a
|
||||||
|
//! login page at worst; every RPC behind it stays gated by the auth
|
||||||
|
//! layer.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::response::Response;
|
||||||
|
use http::{header, HeaderValue, Method, Request, StatusCode, Uri};
|
||||||
|
use include_dir::{include_dir, Dir};
|
||||||
|
|
||||||
|
/// The staged trunk bundle (or the build.rs placeholder page).
|
||||||
|
static DIST: Dir<'_> = include_dir!("$OUT_DIR/webdist");
|
||||||
|
|
||||||
|
/// Content type by file extension. The bundle is fully known at build
|
||||||
|
/// time, so an unknown extension is a programmer omission — served as
|
||||||
|
/// octet-stream rather than panicking.
|
||||||
|
fn content_type(path: &str) -> &'static str {
|
||||||
|
match path.rsplit_once('.').map(|(_, ext)| ext) {
|
||||||
|
Some("html") => "text/html; charset=utf-8",
|
||||||
|
Some("css") => "text/css",
|
||||||
|
Some("js") => "application/javascript",
|
||||||
|
Some("wasm") => "application/wasm",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("ico") => "image/x-icon",
|
||||||
|
Some("txt") => "text/plain; charset=utf-8",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The asset for `uri`, falling back to `index.html` for pathless GETs
|
||||||
|
/// (the app owns its own view state; deep links reload the shell).
|
||||||
|
fn lookup(uri: &Uri) -> (&'static str, &'static [u8]) {
|
||||||
|
let path = uri.path().trim_start_matches('/');
|
||||||
|
let file = if path.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
DIST.get_file(path)
|
||||||
|
};
|
||||||
|
match file {
|
||||||
|
Some(file) => (content_type(path), file.contents()),
|
||||||
|
None => (
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
DIST.get_file("index.html")
|
||||||
|
.map(include_dir::File::contents)
|
||||||
|
// The build script always stages an index.html; an empty
|
||||||
|
// page is the harmless fallback if it ever did not.
|
||||||
|
.unwrap_or(b""),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallback handler: serves bundle assets for GET/HEAD, 404s
|
||||||
|
/// everything else (non-GET traffic belongs to the gRPC route).
|
||||||
|
pub async fn serve_asset(request: Request<Body>) -> Response {
|
||||||
|
if request.method() != Method::GET && request.method() != Method::HEAD {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("static response");
|
||||||
|
}
|
||||||
|
let (content_type, bytes) = lookup(request.uri());
|
||||||
|
let body = if request.method() == Method::HEAD {
|
||||||
|
Body::empty()
|
||||||
|
} else {
|
||||||
|
Body::from(bytes)
|
||||||
|
};
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, HeaderValue::from_static(content_type))
|
||||||
|
.body(body)
|
||||||
|
.expect("static response")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn get(path: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri(path)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn body_string(response: Response) -> String {
|
||||||
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_root_serves_the_app_shell() {
|
||||||
|
let response = serve_asset(get("/")).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()[header::CONTENT_TYPE],
|
||||||
|
"text/html; charset=utf-8"
|
||||||
|
);
|
||||||
|
let html = body_string(response).await;
|
||||||
|
assert!(html.contains("crabidy"), "app shell served");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_paths_fall_back_to_the_shell_get_only() {
|
||||||
|
let response = serve_asset(get("/some/deep/link")).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()[header::CONTENT_TYPE],
|
||||||
|
"text/html; charset=utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
let post = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/not-grpc")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
let response = serve_asset(post).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_types_cover_the_bundle() {
|
||||||
|
assert_eq!(content_type("a.wasm"), "application/wasm");
|
||||||
|
assert_eq!(content_type("a.js"), "application/javascript");
|
||||||
|
assert_eq!(content_type("a.css"), "text/css");
|
||||||
|
assert_eq!(content_type("weird.bin"), "application/octet-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
//! Integration test for the one-port router composition
|
||||||
|
//! (architecture/web-client.md): static assets, the gRPC-web route, and
|
||||||
|
//! the auth layer must coexist. Driven through `tower::oneshot` so no
|
||||||
|
//! socket, no provider backend, and no Tidal device login are needed.
|
||||||
|
//!
|
||||||
|
//! Only meaningful with the `web-ui` feature (the static fallback and
|
||||||
|
//! gRPC-web layer live behind it); a no-web build has nothing to route.
|
||||||
|
#![cfg(feature = "web-ui")]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use crabidy_server::auth::Authenticator;
|
||||||
|
use crabidy_server::rpc::RpcService;
|
||||||
|
use crabidy_server::settings::AuthSettings;
|
||||||
|
use http::{header, Method, Request, StatusCode};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
/// A service wired to dead channels: enough to build the router and
|
||||||
|
/// exercise routing and the pre-handler auth layer (the two things
|
||||||
|
/// under test); no RPC that reaches a handler is sent.
|
||||||
|
fn service() -> RpcService {
|
||||||
|
let (update_tx, _) = tokio::sync::broadcast::channel(4);
|
||||||
|
let (playback_tx, _playback_rx) = flume::unbounded();
|
||||||
|
let (provider_tx, _provider_rx) = flume::unbounded();
|
||||||
|
RpcService::new(update_tx, playback_tx, provider_tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash(password: &str) -> String {
|
||||||
|
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
||||||
|
use argon2::{Argon2, PasswordHasher};
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_root_serves_the_embedded_app_shell() {
|
||||||
|
let router = crabidy_server::build_router(
|
||||||
|
service(),
|
||||||
|
Arc::new(Authenticator::new(&AuthSettings::default())),
|
||||||
|
);
|
||||||
|
let response = router
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let content_type = response.headers()[header::CONTENT_TYPE].to_str().unwrap();
|
||||||
|
assert!(content_type.starts_with("text/html"), "{content_type}");
|
||||||
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&bytes).contains("crabidy"),
|
||||||
|
"app shell (or placeholder) served"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_get_paths_fall_back_to_the_shell() {
|
||||||
|
let router = crabidy_server::build_router(
|
||||||
|
service(),
|
||||||
|
Arc::new(Authenticator::new(&AuthSettings::default())),
|
||||||
|
);
|
||||||
|
let response = router
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/library/deep/link")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert!(response.headers()[header::CONTENT_TYPE]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("text/html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn grpc_web_calls_route_through_the_auth_layer() {
|
||||||
|
// A credentialed server: an unauthenticated gRPC-web POST must be
|
||||||
|
// rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before*
|
||||||
|
// reaching a handler — so the dead channels never matter.
|
||||||
|
let auth = Authenticator::new(&AuthSettings {
|
||||||
|
owner: Some(hash("pw")),
|
||||||
|
queue_owner: None,
|
||||||
|
queue_appender: None,
|
||||||
|
});
|
||||||
|
let router = crabidy_server::build_router(service(), Arc::new(auth));
|
||||||
|
let response = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/crabidy.v1.CrabidyService/Init")
|
||||||
|
.header(header::CONTENT_TYPE, "application/grpc-web+proto")
|
||||||
|
.header("x-grpc-web", "1")
|
||||||
|
.body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0]))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
// gRPC-web reports the status in a header (trailers-only), HTTP 200.
|
||||||
|
let grpc_status = response
|
||||||
|
.headers()
|
||||||
|
.get("grpc-status")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
assert_eq!(grpc_status, Some("16"), "unauthenticated gRPC-web call");
|
||||||
|
|
||||||
|
// With a valid owner credential the layer passes the request
|
||||||
|
// through to the service; owner is allowed for Init, so it is not
|
||||||
|
// rejected. The stub handler then fails on its dead channels — any
|
||||||
|
// outcome other than the auth codes proves the request cleared the
|
||||||
|
// layer and reached the handler.
|
||||||
|
let authed = base64::engine::general_purpose::STANDARD.encode("owner:pw");
|
||||||
|
let response = router
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/crabidy.v1.CrabidyService/Init")
|
||||||
|
.header(header::CONTENT_TYPE, "application/grpc-web+proto")
|
||||||
|
.header("x-grpc-web", "1")
|
||||||
|
.header(header::AUTHORIZATION, format!("Basic {authed}"))
|
||||||
|
.body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0]))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
let grpc_status = response
|
||||||
|
.headers()
|
||||||
|
.get("grpc-status")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
assert_ne!(
|
||||||
|
grpc_status,
|
||||||
|
Some("16"),
|
||||||
|
"authorized call must not be UNAUTHENTICATED"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
grpc_status,
|
||||||
|
Some("7"),
|
||||||
|
"owner must not be PERMISSION_DENIED for Init"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TUI speaks native gRPC (HTTP/2 prior knowledge, no TLS). Moving
|
||||||
|
/// the server from `tonic::transport::Server` to `axum::serve` must not
|
||||||
|
/// break that: bind the real router to a socket and call it with a
|
||||||
|
/// native tonic client. A gRPC *status* back (rather than a transport
|
||||||
|
/// error) proves h2c negotiated and the request reached the service.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_grpc_still_works_through_the_axum_server() {
|
||||||
|
use crabidy_core::proto::crabidy::crabidy_service_client::CrabidyServiceClient;
|
||||||
|
use crabidy_core::proto::crabidy::GetLibraryNodeRequest;
|
||||||
|
|
||||||
|
let router = crabidy_server::build_router(
|
||||||
|
service(),
|
||||||
|
Arc::new(Authenticator::new(&AuthSettings::default())),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
||||||
|
.unwrap()
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.expect("native h2c connect");
|
||||||
|
let mut client = CrabidyServiceClient::new(channel);
|
||||||
|
// Dead channels make the handler fail fast; we only assert the
|
||||||
|
// round trip produced a gRPC status, i.e. the transport worked.
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
client.get_library_node(GetLibraryNodeRequest {
|
||||||
|
path: "/".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("no transport hang");
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"the stub handler errors on dead channels — but the call round-tripped"
|
||||||
|
);
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
28
devenv.nix
28
devenv.nix
|
|
@ -14,11 +14,26 @@ let
|
||||||
d2
|
d2
|
||||||
pkg-config
|
pkg-config
|
||||||
protobuf
|
protobuf
|
||||||
|
cargo-cross
|
||||||
|
# Stream-URL sidecar for the ytdy provider: YouTube caps tokenless
|
||||||
|
# stream URLs at ~1 MiB and yt-dlp is the only maintained cipher
|
||||||
|
# solver (architecture/youtube-rustypipe.md, D2-revised).
|
||||||
|
yt-dlp
|
||||||
|
# Web client toolchain (architecture/web-client.md): trunk builds
|
||||||
|
# cbd-web to wasm, wasm-bindgen-cli must match the crate version,
|
||||||
|
# binaryen provides wasm-opt for release builds.
|
||||||
|
trunk
|
||||||
|
wasm-bindgen-cli
|
||||||
|
binaryen
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
imports = [ ./devenv-rust.nix ];
|
imports = [ ./devenv-rust.nix ];
|
||||||
|
|
||||||
|
# The wasm target for cbd-web; merges with the languages.rust
|
||||||
|
# settings in devenv-rust.nix.
|
||||||
|
languages.rust.targets = [ "wasm32-unknown-unknown" ];
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs;
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs;
|
||||||
};
|
};
|
||||||
|
|
@ -37,6 +52,19 @@ in
|
||||||
echo Welcome to rust devenv
|
echo Welcome to rust devenv
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Builds the web client bundle (cbd-web/dist), which crabidy-server
|
||||||
|
# embeds on its next build (architecture/web-client.md). RUSTFLAGS is
|
||||||
|
# cleared because the mold linker flag from the native toolchain
|
||||||
|
# (RUSTFLAGS wins over target-specific config) breaks rust-lld.
|
||||||
|
scripts.build-web.exec = ''
|
||||||
|
cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk build --release "$@"
|
||||||
|
'';
|
||||||
|
# Dev loop: live-reloading trunk server proxying gRPC-web to a
|
||||||
|
# locally running crabidy-server.
|
||||||
|
scripts.serve-web.exec = ''
|
||||||
|
cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
enterShell = "";
|
enterShell = "";
|
||||||
|
|
||||||
# https://devenv.sh/tasks/
|
# https://devenv.sh/tasks/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "fsdy"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait.workspace = true
|
||||||
|
crabidy-core.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
|
toml.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
flume.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# fsdy — the filesystem provider
|
||||||
|
|
||||||
|
Mounts a local directory at **`/fs`** in the crabidy library. The same
|
||||||
|
engine also powers the server-managed mounts `/queues`, `/bookmarks`,
|
||||||
|
and `/captures` — everything below applies to their on-disk format too.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The provider walks one configured root directory. Every **directory**
|
||||||
|
becomes a queueable library node; every file ending in
|
||||||
|
**`.cbd-track.toml`** becomes a track; everything else (audio files,
|
||||||
|
covers, hidden entries) is invisible to the library. Listing order is
|
||||||
|
case-insensitive by file name — prefix files with numbers to fix an
|
||||||
|
order (the capture/bookmark writers use `0001`-style prefixes for
|
||||||
|
exactly this reason).
|
||||||
|
|
||||||
|
A track file carries the track's metadata plus a reference to the
|
||||||
|
playable thing:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
title = "We Will Rock You" # required
|
||||||
|
artist = "Queen" # optional
|
||||||
|
duration = 122 # optional, seconds
|
||||||
|
|
||||||
|
[album] # optional
|
||||||
|
title = "News of the World"
|
||||||
|
release_date = "1977-10-28"
|
||||||
|
|
||||||
|
# Exactly ONE of the following four:
|
||||||
|
[playable]
|
||||||
|
file = "we-will-rock-you.flac" # local audio; absolute, or relative to
|
||||||
|
# this file's directory (relocatable)
|
||||||
|
# url = "https://example.org/radio.mp3" # http(s) stream
|
||||||
|
# link = "/tidal/artists/1/2/3" # another provider's track
|
||||||
|
# skipped = true # no audio: a capture recorded its
|
||||||
|
# source as uncapturable; shown red,
|
||||||
|
# skipped by playback
|
||||||
|
```
|
||||||
|
|
||||||
|
Malformed track files are skipped with a warning; they never break the
|
||||||
|
listing. `link` playables resolve exactly one hop (a link to a link
|
||||||
|
fails at play time), which keeps cycles impossible.
|
||||||
|
|
||||||
|
## Configuration — `~/.config/crabidy/fsdy.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Absolute path of the directory to expose under /fs.
|
||||||
|
# Default: the platform music directory (e.g. ~/Music). When neither is
|
||||||
|
# available the /fs mount is disabled — the rest of the server runs on.
|
||||||
|
# root = "/home/me/music"
|
||||||
|
```
|
||||||
|
|
||||||
|
## The server-managed instances
|
||||||
|
|
||||||
|
`/queues`, `/bookmarks`, and `/captures` are fsdy instances over
|
||||||
|
`~/.config/crabidy/{queues,bookmarks,captures}/`, written by the server
|
||||||
|
(queue persistence, `w`, `W`) and readable/editable with any file
|
||||||
|
manager — they are just folders of the track files described above.
|
||||||
|
Their top-level folders can be renamed (`e`) and deleted (`d`) from the
|
||||||
|
TUI; `/queues/current` is the continuously persisted play queue and is
|
||||||
|
protected.
|
||||||
|
|
||||||
|
`/captures` additionally lets `d` delete *anything* in its tree — a
|
||||||
|
whole capture, a nested album folder, or a single track (which takes
|
||||||
|
its downloaded audio file with it). Because that removes data from
|
||||||
|
disk that was slow to download, the TUI asks for confirmation first
|
||||||
|
(`delete <name>? [y/N]`); deletes elsewhere stay unconfirmed.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Plan: bookmarks
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs` /
|
||||||
|
`crabidy-server/src/bookmark_store.rs` and/or gates in
|
||||||
|
`quality/bookmarks.md`). Stubs, the proto rpc, and orchestrator wiring
|
||||||
|
exist; the new fsdy/bookmark-store tests fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — fsdy shared naming.** `dir_name` (prefix + shared sanitizer,
|
||||||
|
no suffix; refactor `track_file_name` onto one helper) and
|
||||||
|
`validate_folder_name` (trim, reject separators/NUL/hidden/reserved).
|
||||||
|
Verifies: `dir_names_share_the_track_file_sanitizer`,
|
||||||
|
`folder_name_validation_trims_and_rejects`; gate "no second naming
|
||||||
|
scheme".
|
||||||
|
- [x] **T2 — fsdy mutable top level.** Flags on the editable-instance root
|
||||||
|
listing (skip reserved); `rename_lib_node`/`delete_lib_node`
|
||||||
|
implementations gated on option + direct child + not reserved
|
||||||
|
(`NotSupported` otherwise); rename validates via
|
||||||
|
`validate_folder_name`, refuses collisions (`InvalidInput`), returns
|
||||||
|
the renamed node; delete is idempotent and returns the root listing.
|
||||||
|
Verifies: `editable_instances_flag_only_unreserved_top_level_folders`,
|
||||||
|
`rename_moves_a_top_level_folder`,
|
||||||
|
`rename_rejects_reserved_invalid_and_colliding_targets`,
|
||||||
|
`delete_removes_top_level_folders_idempotently`; gates "Mutable top
|
||||||
|
level".
|
||||||
|
- [x] **T3 — queue_store reuse.** `QueueStore::validate_name` delegates to
|
||||||
|
`fsdy::validate_folder_name` with `current` reserved (behavior
|
||||||
|
unchanged — existing tests must stay green).
|
||||||
|
- [x] **T4 — BookmarkStore capture.** `open`, `capture_with_caps`
|
||||||
|
(iterative pre-order walk over `get_lib_node` /
|
||||||
|
`get_metadata_for_track`, mirrored dirs + link files, caps, temp
|
||||||
|
cleanup on every failure, tmp-and-swap). Verifies: all
|
||||||
|
`bookmark_store::tests`; gates "Capture".
|
||||||
|
- [x] **T5 — TUI capture flow.** `Action::LibraryCaptureNode` (`w`,
|
||||||
|
`Scope::Library`), `Library::selected_queueable()` (bare selection,
|
||||||
|
queueable, path + title), `InputPurpose::Capture { path }` prefilled
|
||||||
|
with the title, `MessageFromUi::CaptureNode { path, name }`,
|
||||||
|
orchestrator arm → new `rpc::capture_library_node` (log failures, keep
|
||||||
|
polling). TUI tests: binding lookup, overlay gating + prefill, submit
|
||||||
|
message. Verifies: new `cbd-tui` tests; gates "TUI".
|
||||||
|
- [x] **T6 — full verification.** Whole workspace suite green;
|
||||||
|
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||||
|
`quality/bookmarks.md` and tick it; no `todo!()` left.
|
||||||
|
- [x] **T7 — live smoke test.** Capture a real Tidal artist subtree into a
|
||||||
|
temp store through the provider layer, browse it through a
|
||||||
|
`/bookmarks` instance, rename it, resolve an album, and fetch a stream
|
||||||
|
URL for one captured link — remove any temporary probe afterwards.
|
||||||
|
Verifies: end-to-end D1/D2/D4 behavior outside unit scope.
|
||||||
|
- [x] **T8 — docs.** `plan/summary.md` section incl. deviations; reconcile
|
||||||
|
`architecture/queue-persistence.md` D8 (saved queues now
|
||||||
|
renamable/deletable) and `architecture/bookmarks.md` if the
|
||||||
|
implementation diverged.
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Plan: captures
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs`,
|
||||||
|
`crabidy-server/src/capture.rs`, `crabidy-server/src/capture_store.rs`
|
||||||
|
and/or gates in `quality/captures.md`). Stubs, the proto field, tidal's
|
||||||
|
flag rule, and the full orchestrator/TUI plumbing exist; the new tests
|
||||||
|
fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — fsdy file-playable constructor.**
|
||||||
|
`TrackFile::from_track_with_file`: metadata like `from_track`, playable
|
||||||
|
`file = <relative sibling>`. Verifies:
|
||||||
|
`from_track_with_file_plays_the_relative_sibling`.
|
||||||
|
- [x] **T2 — capture.rs naming + extension helpers.** `audio_file_name`
|
||||||
|
(shared `ordered_name` semantics via a public fsdy seam) and
|
||||||
|
`extension_for` (Content-Type map, URL-path fallback, `bin`).
|
||||||
|
Verifies: `extension_prefers_content_type_then_url_then_bin`,
|
||||||
|
`audio_files_pair_with_their_toml_names`.
|
||||||
|
- [x] **T3 — shared walk.** Move the bookmark walk into
|
||||||
|
`capture::capture_into`/`write_tree` parameterized by `Caps` and
|
||||||
|
`Sink`; per-track writes go through `write_track` (`Sink::Link` =
|
||||||
|
today's link file). Rewire `BookmarkStore` onto it (delete its copy;
|
||||||
|
keep its public API and `BOOKMARK_CAPS`-equivalent behavior).
|
||||||
|
Verifies: all existing `bookmark_store::tests` unchanged; gates
|
||||||
|
"Shared walk".
|
||||||
|
- [x] **T4 — download sink.** `Downloader::new` (connect timeout),
|
||||||
|
`Sink::Download` in `write_track`: per-track deadline around URL
|
||||||
|
fetch, GET, and the streamed body against `bytes_left`,
|
||||||
|
`error_for_status`, extension from the response, audio first then toml
|
||||||
|
(`from_track_with_file`). Verifies:
|
||||||
|
`download_capture_writes_audio_next_to_pointing_tomls`,
|
||||||
|
`download_capture_is_all_or_nothing`,
|
||||||
|
`download_capture_enforces_its_caps`; gates "Download sink".
|
||||||
|
- [x] **T5 — CaptureStore.** `open` (create dir + build downloader),
|
||||||
|
`capture` → blessing check (`source_allows_download`: node or track
|
||||||
|
parent) then `capture_with_caps(DOWNLOAD_CAPS)` → `capture_into`.
|
||||||
|
Verifies: `capture_requires_the_root_download_blessing`,
|
||||||
|
`capturing_a_single_blessed_track_writes_one_pair`,
|
||||||
|
`capture_validates_names_and_overwrites`; gates "Opt-in".
|
||||||
|
- [x] **T6 — TUI tests.** Binding test for `W` (Library scope, shift),
|
||||||
|
`selected_downloadable` gating (queueable-but-not-downloadable stays
|
||||||
|
closed; tracks inherit the node flag), overlay label `capture`,
|
||||||
|
submit carries `download: true`. Verifies: new `cbd-tui` tests; gates
|
||||||
|
"TUI".
|
||||||
|
- [x] **T7 — full verification.** Whole workspace suite green;
|
||||||
|
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||||
|
`quality/captures.md` and tick it; no `todo!()` left.
|
||||||
|
- [x] **T8 — live smoke test.** Capture a real Tidal track with download
|
||||||
|
through the provider layer into a temp store (a single track keeps the
|
||||||
|
probe cheap; the multi-track walk is unit-covered), verify the audio
|
||||||
|
exists with a plausible size, replay through a `/captures` instance,
|
||||||
|
and play-resolve it — remove any temporary probe afterwards.
|
||||||
|
- [x] **T9 — docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/captures.md` (tidal flag rule, anything else
|
||||||
|
that moved).
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Plan: cbd-bundle
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `cbd/src/main.rs`
|
||||||
|
and/or gates in `quality/cbd-bundle.md`).
|
||||||
|
|
||||||
|
- [x] **T1 — Server library extraction.** Move `playback`/`provider`/
|
||||||
|
`rpc` and the command/message enums into the `crabidy-server` lib;
|
||||||
|
add `serve(addr)` + `LISTEN_ADDR`; thin `main.rs`. Verifies: the
|
||||||
|
moved server suite passes unchanged; gates "Refactor".
|
||||||
|
- [x] **T2 — TUI library extraction.** `cbd-tui/src/lib.rs` with
|
||||||
|
`run(config)` (orchestrate + run_ui + channels); thin `main.rs` keeps
|
||||||
|
file tracing + config. Verifies: the moved TUI suite passes
|
||||||
|
unchanged.
|
||||||
|
- [x] **T3 — cbd crate.** Workspace member; main = shared file tracing,
|
||||||
|
`cbd-tui.toml` config, spawn `serve(LISTEN_ADDR)`, `wait_for_server`
|
||||||
|
(scheme-stripped TCP poll, adopt-on-occupied-port, dead-server
|
||||||
|
error, bounded attempts), then `cbd_tui::run`. Verifies:
|
||||||
|
`readiness_polls_until_the_socket_accepts`,
|
||||||
|
`readiness_gives_up_and_reports_a_dead_server`; gates "Bundled
|
||||||
|
behavior".
|
||||||
|
- [x] **T4 — Full verification.** Workspace suite green; clippy/fmt/
|
||||||
|
taplo/markdownlint clean.
|
||||||
|
- [x] **T5 — Live smoke test.** Boot the extracted `serve()` on a free
|
||||||
|
port through `wait_for_server` (temporary ignored probe, removed
|
||||||
|
after passing): the real stack — tidal login, providers, playback,
|
||||||
|
queue restore — accepted a TCP connection in ~1 s.
|
||||||
|
- [x] **T6 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/cbd-bundle.md`.
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Plan: fs-provider
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs`
|
||||||
|
and/or gates in `quality/fs-provider.md`). Stubs, tests, and workspace
|
||||||
|
wiring exist; all 13 fsdy tests fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — `TrackFile::parse` + `playable()` validation.** TOML parse
|
||||||
|
into the schema structs; `playable()` enforces exactly-one, http(s)
|
||||||
|
scheme, absolute link, no `/fs` link. Verifies:
|
||||||
|
`parse_accepts_a_full_track_file`, `parse_defaults_optional_metadata`,
|
||||||
|
`parse_rejects_wrong_playable_cardinality`,
|
||||||
|
`parse_rejects_invalid_playables`; gate "link chains impossible".
|
||||||
|
- [x] **T2 — `TrackFile::to_track`.** Single conversion site; link rewrites
|
||||||
|
the path, file/url keep it; album/duration mapped. Verifies:
|
||||||
|
`to_track_rewrites_the_path_only_for_links`; gate "single
|
||||||
|
file-to-Track conversion".
|
||||||
|
- [x] **T3 — path scheme: `is_track_path` + `disk_path`.** Suffix+prefix
|
||||||
|
check; decode-validate-join with `.`/`..`/empty/separator rejection in
|
||||||
|
one helper. Verifies: `track_paths_need_the_suffix_and_the_provider_prefix`,
|
||||||
|
`client_paths_cannot_escape_the_root`; gate "one validation place".
|
||||||
|
- [x] **T4 — `init` + `settings`.** Parse `Settings`, default root
|
||||||
|
`dirs::audio_dir()`, `Config` error when neither; nonexistent root
|
||||||
|
accepted; write-back serialization. Verifies:
|
||||||
|
`settings_round_trip_through_the_config_write_back`,
|
||||||
|
`init_accepts_a_root_that_does_not_exist_yet`.
|
||||||
|
- [x] **T5 — `read_track_file` + `get_metadata_for_track` +
|
||||||
|
`get_urls_for_track`.** tokio::fs read, parse, playable dispatch
|
||||||
|
(relative file joined onto the track file's dir; link →
|
||||||
|
`MalformedPath` warn). Verifies: `urls_resolve_per_playable_kind`,
|
||||||
|
`metadata_of_a_link_track_carries_the_target_path`; gates "plain
|
||||||
|
paths/URLs only", "no file contents in logs".
|
||||||
|
- [x] **T6 — `list_dir` + `get_lib_node` + `get_lib_root`.** Sorted
|
||||||
|
case-insensitive listing; skip symlinks/hidden/non-UTF-8/foreign/
|
||||||
|
broken (warn with file name); encoded child paths; parent links.
|
||||||
|
Verifies: `listing_sorts_and_skips_foreign_hidden_and_broken_entries`,
|
||||||
|
`listing_a_missing_directory_is_an_error_not_a_panic`,
|
||||||
|
`nodes_link_back_to_their_parent`,
|
||||||
|
`resolving_a_tree_streams_chunks_in_listing_order`; gates "symlinks
|
||||||
|
skipped", "listing order = resolve order", "tokio::fs only".
|
||||||
|
- [x] **T7 — orchestrator wiring.** `fs_client: Option<Arc<fsdy::Client>>`
|
||||||
|
in `ProviderOrchestrator`; non-fatal init from `fsdy.toml` with
|
||||||
|
write-back; `/fs` arms in every trait method; root child only when
|
||||||
|
present; `crabidy-server/Cargo.toml` gains `fsdy`. Verifies: gates
|
||||||
|
under "Orchestrator wiring"; workspace build.
|
||||||
|
- [x] **T8 — full verification.** All fsdy tests + whole workspace suite
|
||||||
|
green; clippy/fmt/taplo/markdownlint clean; walk the remaining gates
|
||||||
|
and tick them; no `todo!()` left.
|
||||||
|
- [x] **T9 — live smoke test.** Build a real tree under a temp root
|
||||||
|
(nested dirs, a relative-file track, a url track, a `/tidal` link
|
||||||
|
track), run the server pointing at it, list `/fs` over the provider
|
||||||
|
layer, and resolve a directory — remove any temporary probe
|
||||||
|
afterwards. Verifies: end-to-end behavior of D2/D4 outside unit
|
||||||
|
scope.
|
||||||
|
- [x] **T10 — docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/fs-provider.md` if the implementation
|
||||||
|
diverged.
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Plan — help modal (cbd-tui)
|
||||||
|
|
||||||
|
Ordered tasks for the `implement` stage. Inputs: `architecture/help-modal.md`,
|
||||||
|
stubs in `cbd-tui/src/app/{bindings,help,mod}.rs`, gates in
|
||||||
|
`quality/help-modal.md`. Run tests with `devenv shell -- cargo test -p cbd-tui`.
|
||||||
|
|
||||||
|
## 1. Binding lookup
|
||||||
|
|
||||||
|
- [x] Implement `bindings::lookup`: scan `BINDINGS` in order; when `help_open`
|
||||||
|
only `Scope::Help` entries are eligible, otherwise `Scope::Global` plus
|
||||||
|
the scope matching `focus`. Compare chords with `SHIFT` ignored for
|
||||||
|
`KeyCode::Char` codes, exact modifiers otherwise. Return the first match.
|
||||||
|
**Verify**: all `app::bindings::tests::*lookup*`-related tests pass
|
||||||
|
(`global_bindings_match_in_any_focus`,
|
||||||
|
`pane_bindings_only_match_their_own_pane`, `same_chord_resolves_per_pane`,
|
||||||
|
`shift_is_ignored_for_char_codes`, `control_must_match_exactly`,
|
||||||
|
`open_help_swallows_everything_but_close`,
|
||||||
|
`help_scope_never_matches_while_help_is_closed`).
|
||||||
|
- [x] Implement `bindings::key_label`: `Char(' ')` → `"Space"`, other chars →
|
||||||
|
the char itself, `Tab`/`Enter`/`Esc` named, `CONTROL` prefix `"Ctrl+"`;
|
||||||
|
no panic on any input (fall back to `Debug`-ish formatting for unbound
|
||||||
|
codes). **Verify**: `key_labels_are_human_readable` passes; gate "no
|
||||||
|
panics on user input".
|
||||||
|
|
||||||
|
## 2. Action dispatch
|
||||||
|
|
||||||
|
- [x] Implement `App::dispatch` as one `match action` reproducing, arm for
|
||||||
|
arm, the behavior of the old key match in `main.rs` (send via `self.tx`
|
||||||
|
with `let _ =`, or call `self.library`/`self.queue` methods).
|
||||||
|
`OpenHelp`/`CloseHelp` set `show_help`; `Quit` returns
|
||||||
|
`DispatchResult::Quit`; everything else `Continue`.
|
||||||
|
**Verify**: all `app::tests::*` pass; gate "behavior parity" (cross-check
|
||||||
|
against the pre-change `main.rs` match arm by arm).
|
||||||
|
|
||||||
|
## 3. Wire the event loop
|
||||||
|
|
||||||
|
- [x] Replace the key match in `main.rs run_ui` with: on key press,
|
||||||
|
`bindings::lookup(app.focus, app.show_help, key)` then
|
||||||
|
`app.dispatch(action)`, breaking the loop on `DispatchResult::Quit`.
|
||||||
|
Keep the `KeyEventKind::Press` filter. Remove the now-dead imports
|
||||||
|
(`KeyCode`, `KeyModifiers`, `UiFocus`, `MessageFromUi` uses that move
|
||||||
|
into `dispatch`). **Verify**: `cargo check` clean, no key handling left
|
||||||
|
in `main.rs` (gate "single source of truth"); manual smoke via `?`.
|
||||||
|
|
||||||
|
## 4. Help modal rendering
|
||||||
|
|
||||||
|
- [x] Implement `help::popup_area`: content-sized centered `Rect` clamped to
|
||||||
|
the frame. **Verify**: `popup_never_exceeds_the_frame`.
|
||||||
|
- [x] Implement `help::render`: `Clear` the popup area, draw a bordered block
|
||||||
|
(style matching the panes: rounded borders, `COLOR_PRIMARY`), a short
|
||||||
|
usage paragraph naming the Library and Queue panes and `Tab` to switch,
|
||||||
|
then `BINDINGS` grouped by scope in table order with labels from
|
||||||
|
`key_label`. Truncate lines that don't fit; never panic on tiny frames.
|
||||||
|
**Verify**: `help_lists_bindings_from_the_table`,
|
||||||
|
`help_explains_basic_usage`, `help_survives_tiny_terminals` pass; gates
|
||||||
|
"modality" (overlay drawn last, `Clear` used) and "no panics".
|
||||||
|
|
||||||
|
## 5. Polish and gates
|
||||||
|
|
||||||
|
- [x] Resolve the `TODO(api-design)` on `LibraryQueueNext`: check what
|
||||||
|
`MessageFromUi::QueueTracks` does in `crabidy-server` and fix the
|
||||||
|
description text if needed. **Verify**: gate "TODO resolved".
|
||||||
|
- [x] Sweep: doc comments still accurate, no dead-code warnings left for
|
||||||
|
`bindings`/`help`, no new dependencies.
|
||||||
|
**Verify**: `devenv shell -- cargo fmt --check`,
|
||||||
|
`devenv shell -- cargo clippy -p cbd-tui` (no new warnings),
|
||||||
|
`devenv shell -- cargo test -p cbd-tui` all green; every box in
|
||||||
|
`quality/help-modal.md` checked.
|
||||||
|
- [x] Write `plan/summary.md` (or a `help-modal` section in it) recording any
|
||||||
|
deviations from this plan.
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Plan: incremental-captures
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests and/or gates in
|
||||||
|
`quality/incremental-captures.md`).
|
||||||
|
|
||||||
|
- [x] **T1 — Skipped playable in fsdy + proto.** `PlayableSpec.skipped`,
|
||||||
|
`Playable::Skipped`, cardinality validation, `from_track_skipped`,
|
||||||
|
`from_track` preserves skipped-ness, `to_track` sets `is_skipped`;
|
||||||
|
`Track.is_skipped = 6` in the proto; fix all struct literals. Verifies:
|
||||||
|
fsdy parse/round-trip tests; gates "Skipped playable".
|
||||||
|
- [x] **T2 — Two-phase walk + incremental download sink.** Enumerate
|
||||||
|
(caps) then fetch; `Sink::Download` writes into the final folder,
|
||||||
|
reuses satisfied entries, writes skipped tomls for uncapturable tracks,
|
||||||
|
keeps progress on abort; `Sink::Link` keeps tmp-and-swap. Progress
|
||||||
|
callback plumbed through. Verifies: capture/capture_store tests
|
||||||
|
(resume, reuse, skipped toml, abort-keeps-progress, caps); gates
|
||||||
|
"Incremental capture".
|
||||||
|
- [x] **T3 — Proto `CaptureProgress` + accept-then-stream RPC.** New
|
||||||
|
oneof update; provider validates then replies and spawns the walk with
|
||||||
|
a bounded progress channel; rpc forwards progress into the update
|
||||||
|
broadcast. Verifies: gates "Progress + RPC".
|
||||||
|
- [x] **T4 — Playback skip.** Flag-based skip without provider call;
|
||||||
|
skip loop bounded by queue length. Verifies: playback tests; gate
|
||||||
|
"Playback".
|
||||||
|
- [x] **T5 — TUI.** Red skipped tracks (queue + library), capture
|
||||||
|
progress lines with expiry, warnings in help + input label, focused
|
||||||
|
selection contrast fix. Verifies: cbd-tui render/dispatch tests; gates
|
||||||
|
"TUI".
|
||||||
|
- [x] **T6 — Full verification.** Workspace suite green; clippy/fmt/
|
||||||
|
taplo/markdownlint clean; quality gates ticked.
|
||||||
|
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/incremental-captures.md`.
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Plan — editable and deletable nodes
|
||||||
|
|
||||||
|
Ordered tasks for the `implement` stage. Inputs: `architecture/node-editing.md`,
|
||||||
|
stubs across the workspace, gates in `quality/node-editing.md`. Tests:
|
||||||
|
`devenv shell -- cargo test --workspace` (10 failing at plan time = the
|
||||||
|
target: 5 in tidaldy, 5 in cbd-tui). Use the session-local `CARGO_TARGET_DIR`
|
||||||
|
if `target/` contains artifacts owned by the repo owner.
|
||||||
|
|
||||||
|
## 1. Term-list mutations (tidaldy)
|
||||||
|
|
||||||
|
- [x] Implement `rename_search_term(old, new)`: under one write lock, if
|
||||||
|
`new` already exists remove the `old` slot (merge); else if `old`
|
||||||
|
exists replace it in place; else push `new` (stale-client
|
||||||
|
forgiveness). Implement `remove_search_term(term)`: `retain`
|
||||||
|
everything but `term`. Both poison-tolerant like
|
||||||
|
`register_search_term`, no await under the lock. **Verify**:
|
||||||
|
`rename_replaces_in_place_and_merges_duplicates` passes; gate "lock
|
||||||
|
discipline".
|
||||||
|
|
||||||
|
## 2. Provider rename/delete (tidaldy)
|
||||||
|
|
||||||
|
- [x] `rename_lib_node`: trim new title → `InvalidInput` if empty;
|
||||||
|
`parse_path(path)?` must be `TidalPath::SearchTerm(old)` else
|
||||||
|
`NotSupported`; decode old term, `rename_search_term`, return
|
||||||
|
`get_lib_node(join_path(parent, encode_segment(new)))`. **Verify**:
|
||||||
|
`rename_rejects_empty_titles_and_foreign_paths` passes (validation
|
||||||
|
before any network call).
|
||||||
|
- [x] `delete_lib_node`: `parse_path(path)?` must be `SearchTerm` else
|
||||||
|
`NotSupported`; decode + `remove_search_term` (idempotent); return
|
||||||
|
`get_lib_node(/tidal/search)`. **Verify**: `delete_rejects_foreign_paths`
|
||||||
|
and `delete_removes_terms_idempotently_and_returns_the_parent` pass.
|
||||||
|
- [x] Flag term children in the `Search` arm of `get_lib_node`:
|
||||||
|
`is_editable: true, is_deletable: true` via struct update. **Verify**:
|
||||||
|
`search_node_lists_created_terms` passes (extended assertion).
|
||||||
|
|
||||||
|
## 3. Server handlers (crabidy-server)
|
||||||
|
|
||||||
|
- [x] Replace the `todo!()` bodies of `rpc.rs
|
||||||
|
rename_library_node`/`delete_library_node`: record span fields,
|
||||||
|
bounded(1) rendezvous with the matching `ProviderCommand`, map errors
|
||||||
|
exactly like `create_library_node` (`NotSupported` →
|
||||||
|
`failed_precondition` with an operation-specific message,
|
||||||
|
`InvalidInput` → `invalid_argument`, rest → `internal`). **Verify**:
|
||||||
|
gates "error mapping" + "bounded rendezvous"; `cargo check`.
|
||||||
|
(Orchestrator routing and the command-loop arms were finalized at
|
||||||
|
stub time — re-read them against the gate rather than re-doing them.)
|
||||||
|
|
||||||
|
## 4. TUI library accessors + marker (cbd-tui)
|
||||||
|
|
||||||
|
- [x] Implement `Library::selected_editable` / `selected_deletable` from
|
||||||
|
`list_state.selected()` + the `UiItem` flags. **Verify**:
|
||||||
|
`edit_opens_the_overlay_prefilled_only_on_editable_selections`,
|
||||||
|
`delete_sends_only_for_deletable_selections`,
|
||||||
|
`rename_submit_sends_the_trimmed_new_title`,
|
||||||
|
`rename_cancels_and_emptied_buffers_send_nothing` pass (dispatch and
|
||||||
|
overlay logic already landed with the stubs).
|
||||||
|
- [x] Render the `[e]`/`[d]`/`[ed]` suffix for modifiable items in
|
||||||
|
`Library::render`, `COLOR_SECONDARY` like `[%]`. **Verify**:
|
||||||
|
`modifiable_items_are_marked_and_the_rename_overlay_is_labelled`
|
||||||
|
passes; gate "marked in UI".
|
||||||
|
|
||||||
|
## 5. TUI ↔ server wiring (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `RpcClient::rename_library_node`: send the rpc, evict the old path and
|
||||||
|
the parent (`crabidy_core::parent_path`), insert + return the renamed
|
||||||
|
node. `delete_library_node`: send the rpc, evict the deleted path and
|
||||||
|
the parent, insert + return the returned parent node. **Verify**: gate
|
||||||
|
"cache eviction" (read; the cache is private, as with create).
|
||||||
|
- [x] `main.rs poll`: replace the `todo!()` arms — `RenameNode` calls
|
||||||
|
`rename_library_node`, `DeleteNode` calls `delete_library_node`; on
|
||||||
|
success `ReplaceLibraryNode(node)`, on failure `error!` and leave the
|
||||||
|
UI unchanged. **Verify**: gate "failures never panic".
|
||||||
|
|
||||||
|
## 6. End-to-end + gates sweep
|
||||||
|
|
||||||
|
- [x] Exercise the full path against the live API if the local tidal config
|
||||||
|
is available (create a term, rename it, delete it, confirm the parent
|
||||||
|
listing and that a queued search track still resolves a URL); note the
|
||||||
|
outcome in the summary. If unavailable, rely on the offline tests and
|
||||||
|
say so.
|
||||||
|
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests green,
|
||||||
|
every `quality/node-editing.md` box checked, docs updated where
|
||||||
|
behavior shifted. Append the outcome + deviations to `plan/summary.md`.
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Plan — progressive queueing
|
||||||
|
|
||||||
|
Ordered tasks for the `implement` stage. Inputs:
|
||||||
|
`architecture/progressive-queueing.md`, the workspace stubs, gates in
|
||||||
|
`quality/progressive-queueing.md`. Tests:
|
||||||
|
`devenv shell -- cargo test --workspace` (13 failing at plan time = the
|
||||||
|
target: 5 in crabidy-core, 5 in crabidy-server, 2 in cbd-tui, 1 in tidaldy).
|
||||||
|
Use the session-local `CARGO_TARGET_DIR` if `target/` contains artifacts
|
||||||
|
owned by the repo owner.
|
||||||
|
|
||||||
|
## 1. Default chunked resolve (crabidy-core)
|
||||||
|
|
||||||
|
- [x] Implement the `resolve_tracks_into` default body: track path →
|
||||||
|
single chunk from `get_metadata_for_track` (failure → warn, `Ok`, no
|
||||||
|
chunk — matches today's skip); node path → depth-first **pre-order**
|
||||||
|
walk (stack of paths, children pushed in reverse so siblings pop in
|
||||||
|
listing order), per node: skip unreadable with a warning (root
|
||||||
|
failure → `Err`), skip non-queueable, send `tracks` as one chunk when
|
||||||
|
non-empty; a failed send (receiver gone) → return `Ok` immediately.
|
||||||
|
**Verify**: all 5 `default_resolve_*` tests pass.
|
||||||
|
|
||||||
|
## 2. PendingResolve state machine (crabidy-server lib)
|
||||||
|
|
||||||
|
- [x] Implement `new`/`cancel_flag`/`cancel`/`applied`/`apply_chunk`.
|
||||||
|
`apply_chunk`: `Replace` → `replace_with_tracks` for the first chunk
|
||||||
|
(returns current track), then mutate kind to `Append`; `Append` →
|
||||||
|
`append_tracks`; `InsertAfter(pos)` → `insert_tracks(pos)` then
|
||||||
|
advance the cursor by `tracks.len()` (clamping is `insert_tracks`'
|
||||||
|
job); count `applied`. **Verify**: the 5 op tests pass
|
||||||
|
(`replace_op_*`, `insert_after_op_*` ×2, `append_op_*`,
|
||||||
|
`cancel_flag_*`).
|
||||||
|
|
||||||
|
## 3. Playback loop wiring (crabidy-server)
|
||||||
|
|
||||||
|
- [x] Implement `broadcast_queue` (snapshot → `Queue`, set `resolving`
|
||||||
|
from the pending map, broadcast) and route the remaining direct
|
||||||
|
`StreamUpdate::Queue(...)` broadcasts in `handle_command` through it.
|
||||||
|
**Verify**: gate "resolving is true iff at least one op is pending".
|
||||||
|
- [x] Implement `start_resolve`: allocate op id, insert `PendingResolve`,
|
||||||
|
immediate `broadcast_queue`, spawn the forwarder
|
||||||
|
(`tokio::spawn` + `in_current_span`): per path in order — check the
|
||||||
|
cancel flag (set → stop), open `flume::bounded(4)` chunk channel,
|
||||||
|
send `ProviderCommand::ResolveTracks`, forward each chunk as
|
||||||
|
`ApplyResolvedChunk` (checking the cancel flag between chunks, drop
|
||||||
|
the receiver on cancel); after all paths (or on any exit path) send
|
||||||
|
`ResolveFinished`. Track the op's paths for the zero-track warning.
|
||||||
|
**Verify**: gates "immediate feedback", "cancellation propagates",
|
||||||
|
"no panics"; `cargo check`.
|
||||||
|
- [x] Implement `apply_resolved_chunk` (look up op — unknown id: drop
|
||||||
|
silently; lock queue, `apply_chunk`, `broadcast_queue`, then
|
||||||
|
`play_if_some` outside the locks), `finish_resolve` (remove op, warn
|
||||||
|
when `applied() == 0` with the op's paths, `broadcast_queue`), and
|
||||||
|
`cancel_pending_resolves` (cancel + clear map; no broadcast needed —
|
||||||
|
the caller mutates and broadcasts next). **Verify**: gates "playback
|
||||||
|
starts with the first chunk", "late chunks dropped"; existing
|
||||||
|
playback tests still green.
|
||||||
|
|
||||||
|
## 4. tidaldy page-streamed resolve
|
||||||
|
|
||||||
|
- [x] Implement `make_paginated_request_into` (same loop as
|
||||||
|
`make_paginated_request`, `sink(page.items)` per page, stop on
|
||||||
|
`false`). **Verify**: gate "token/refresh path reused"; `cargo check`.
|
||||||
|
- [x] Implement the `resolve_tracks_into` override: `parse_path` first
|
||||||
|
(foreign → `MalformedPath` before any I/O); track paths → one
|
||||||
|
metadata chunk; pre-order worklist walk where `Playlist` and `Album`
|
||||||
|
nodes stream `tracks/items` pages as chunks (`Track::to_proto` per
|
||||||
|
page, no playlist-metadata fetch) and all other node kinds fall back
|
||||||
|
to `get_lib_node` (tracks as one chunk when queueable, children onto
|
||||||
|
the worklist in listing order). Failed sends → stop, `Ok`.
|
||||||
|
**Verify**: `resolve_rejects_foreign_paths_before_any_network_call`
|
||||||
|
passes; gate "order preserved end-to-end".
|
||||||
|
|
||||||
|
## 5. TUI indicator (cbd-tui)
|
||||||
|
|
||||||
|
- [x] Implement `Queue::loading_dots` (1 + (elapsed_ms / 400) % 3 dots)
|
||||||
|
and render the pseudo-item: when `resolving`, push one extra
|
||||||
|
`ListItem` (dots, `COLOR_SECONDARY`) after the track rows, computed
|
||||||
|
from a monotonic clock at render time; `self.list` stays untouched.
|
||||||
|
**Verify**: `loading_dots_cycle_one_to_three`,
|
||||||
|
`resolving_queue_renders_trailing_dots_item` pass; the three guard
|
||||||
|
tests stay green.
|
||||||
|
|
||||||
|
## 6. End-to-end + gates sweep
|
||||||
|
|
||||||
|
- [x] Exercise the full path against the live API if the local tidal
|
||||||
|
config is available (temporary ignored probe: resolve a multi-album
|
||||||
|
artist through the orchestrator, assert multiple chunks arrive, in
|
||||||
|
listing order, first chunk before the walk completes; drop the
|
||||||
|
receiver mid-stream and confirm fetching stops). Note the outcome in
|
||||||
|
the summary; if unavailable, rely on the offline tests and say so.
|
||||||
|
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests
|
||||||
|
green, every `quality/progressive-queueing.md` box checked, docs
|
||||||
|
updated where behavior shifted. Append the outcome + deviations to
|
||||||
|
`plan/summary.md`.
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Plan: queue-persistence
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs` /
|
||||||
|
`crabidy-server/src/queue_store.rs` and/or gates in
|
||||||
|
`quality/queue-persistence.md`). Stubs and workspace wiring exist; the new
|
||||||
|
fsdy/queue-store tests fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — fsdy instance parameterization.** `Client::new(provider_root,
|
||||||
|
disk_root)` with validation; replace every hardcoded `"/fs"`/`"/fs/"`
|
||||||
|
inside `Client` methods with instance state (`disk_path`,
|
||||||
|
`is_track_path`, `list_dir` title/paths, `get_lib_root`); `init` builds
|
||||||
|
the `/fs` instance via `new`. Verifies:
|
||||||
|
`instances_serve_their_own_provider_root`,
|
||||||
|
`instance_paths_cannot_escape_their_root_either`,
|
||||||
|
`new_rejects_malformed_roots`; gates "fsdy instances" (prefix
|
||||||
|
derivation, single traversal site).
|
||||||
|
- [x] **T2 — fsdy serialization.** `TrackFile::from_track` (uniform link
|
||||||
|
playable), `to_toml`, `track_file_name` (zero-padded prefix +
|
||||||
|
sanitized title). Verifies: `from_track_round_trips_through_a_link_file`,
|
||||||
|
`from_track_serializes_sparse_metadata`,
|
||||||
|
`track_file_names_sort_in_queue_order_and_stay_plain`,
|
||||||
|
`links_into_fs_instances_are_legal_and_one_hop` (rule relaxation, done
|
||||||
|
at stub time).
|
||||||
|
- [x] **T3 — QueueStore.** `open` (create_dir_all), `validate_name`,
|
||||||
|
`save`/`persist_current` via one tmp-and-swap writer (entries +
|
||||||
|
`.queue-state.toml` sidecar), `load_current` (sorted listing, skip
|
||||||
|
broken, default state on sidecar defects). Verifies: all
|
||||||
|
`queue_store::tests` except `persister_writes_the_latest_snapshot`;
|
||||||
|
gates "Queue store".
|
||||||
|
- [x] **T4 — persister task.** `spawn_persister`: watch changes →
|
||||||
|
debounce → skip-if-equal → `persist_current`, warn on failure, exit on
|
||||||
|
sender drop. Verifies: `persister_writes_the_latest_snapshot`; gates
|
||||||
|
"playback wiring" (debounce/skip, warnings only).
|
||||||
|
- [x] **T5 — playback loop wiring.** `Playback::new` takes
|
||||||
|
`Option<Arc<QueueStore>>`; watch sender + persist sends from
|
||||||
|
`broadcast_queue`, `play`, and the shuffle/repeat toggles; `run`
|
||||||
|
spawns the persister; `PlaybackCommand::SaveQueue { name, result_tx }`
|
||||||
|
handled on the loop (snapshot, spawn write, reply);
|
||||||
|
`restore_current` applies tracks + position + mods before `run`, no
|
||||||
|
autoplay. Playback-level tests (restore → Init snapshot; SaveQueue
|
||||||
|
ok/empty; a queue mutation reaches the store). Verifies: new
|
||||||
|
`playback::tests`; gates "Playback wiring".
|
||||||
|
- [x] **T6 — orchestrator + server startup.** `queues_client:
|
||||||
|
Option<Arc<fsdy::Client>>` mounted over `QueueStore::dir` at
|
||||||
|
`/queues`; routing arms in every trait method; root child when
|
||||||
|
present; `main.rs` builds the store (non-fatal), restores, spawns
|
||||||
|
everything in order. Verifies: gates "RPC and orchestrator";
|
||||||
|
workspace build.
|
||||||
|
- [x] **T7 — rpc `save_queue`.** Send `SaveQueue` to the playback loop,
|
||||||
|
map `SaveQueueError` → `Status` (invalid_argument /
|
||||||
|
failed_precondition / internal). Verifies: gate "RPC and
|
||||||
|
orchestrator" (error mapping).
|
||||||
|
- [x] **T8 — TUI save flow.** `Action::QueueSaveAs` (`w`, `Scope::Queue`),
|
||||||
|
`InputPurpose::SaveQueue` (+ overlay label), `MessageFromUi::SaveQueue`,
|
||||||
|
orchestrator arm → `rpc::save_queue`. TUI tests: binding lookup,
|
||||||
|
overlay open only with a non-empty queue, submit sends the trimmed
|
||||||
|
name. Verifies: new `cbd-tui` tests; gates "TUI".
|
||||||
|
- [x] **T9 — full verification.** Whole workspace suite green;
|
||||||
|
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||||
|
`quality/queue-persistence.md` and tick it; no `todo!()` left.
|
||||||
|
- [x] **T10 — live smoke test.** Temp store + real tree: persist a mixed
|
||||||
|
queue (tidal + fs tracks), reload it, browse `/queues` through the
|
||||||
|
provider instance, queue a saved folder via the resolve walk — remove
|
||||||
|
any temporary probe afterwards. Verifies: end-to-end D2/D5 behavior
|
||||||
|
outside unit scope.
|
||||||
|
- [x] **T11 — docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/fs-provider.md` (one-hop links) and
|
||||||
|
`architecture/queue-persistence.md` if the implementation diverged.
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Plan — roles and rights
|
||||||
|
|
||||||
|
From `architecture/roles-auth.md` and `quality/roles-auth.md`.
|
||||||
|
|
||||||
|
- [x] **Deps**: workspace `argon2` (with `std`), `http`, `tower`;
|
||||||
|
`crabidy-server` gains `argon2`, `base64`, `http`, `tower`,
|
||||||
|
`clap`; `cbd-tui` gains `base64`. Verify: workspace builds.
|
||||||
|
- [x] **Server settings** (`crabidy-server/src/settings.rs`):
|
||||||
|
`ServerSettings { auth: AuthSettings }`,
|
||||||
|
`AuthSettings { owner, queue_owner, queue_appender: Option<String> }`;
|
||||||
|
`load(config_dir)` — absent file ⇒ defaults, malformed file ⇒
|
||||||
|
startup error. Verify: unit tests for all three cases.
|
||||||
|
- [x] **Auth core** (`crabidy-server/src/auth.rs`): `Role` (ordered),
|
||||||
|
`minimum_role(method) -> Role` default-deny table,
|
||||||
|
`Authenticator` (parse Basic header, argon2 verify, success
|
||||||
|
cache, disabled mode). Verify: unit tests — header parsing
|
||||||
|
never panics, wrong user/password indistinguishable, matrix
|
||||||
|
samples per role, unknown method ⇒ owner, cache fed only by
|
||||||
|
successes, disabled mode allows all without header.
|
||||||
|
- [x] **Tower layer** (`auth.rs`): `AuthLayer`/`AuthService` checking
|
||||||
|
`authorization` against the method's minimum role before the
|
||||||
|
inner service; denials answer trailers-only via
|
||||||
|
`Status::into_http()`. Verify: service-level tests with a
|
||||||
|
counting inner service (deny short-circuits, allow forwards,
|
||||||
|
unauthenticated vs permission-denied codes).
|
||||||
|
- [x] **Wire-up** (`lib.rs::serve`): load settings, fail startup on
|
||||||
|
malformed config, install the layer. Verify: existing tests
|
||||||
|
still pass; layer test covers enforcement.
|
||||||
|
- [x] **hash-password** (`main.rs` + clap): subcommand reads stdin,
|
||||||
|
prints PHC string; round-trip test hash → authenticator accepts.
|
||||||
|
- [x] **Client** (`cbd-tui`): `user`/`password` config options (and
|
||||||
|
flags), auth interceptor attaching a precomputed Basic header to
|
||||||
|
every request, type alias for the intercepted client. Verify:
|
||||||
|
unit tests — no creds ⇒ no header, creds ⇒ header present.
|
||||||
|
- [x] **Docs**: root README (config table row, `cbd-tui.toml` options,
|
||||||
|
security note), new `crabidy-server.toml` section; architecture
|
||||||
|
cross-links. Verify: markdownlint.
|
||||||
|
- [x] **Gates**: run the full suite + clippy + fmt; check off
|
||||||
|
`quality/roles-auth.md`; write `plan/summary.md` section.
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Plan — search via creatable nodes
|
||||||
|
|
||||||
|
Ordered tasks for the `implement` stage. Inputs: `architecture/search.md`,
|
||||||
|
stubs across all five crates, gates in `quality/search.md`. Tests:
|
||||||
|
`devenv shell -- cargo test -p crabidy-core -p tidaldy -p cbd-tui`
|
||||||
|
(11 failing at plan time = the target). Note: use a session-local
|
||||||
|
`CARGO_TARGET_DIR` if `target/` contains root-owned artifacts from the
|
||||||
|
repo owner's builds.
|
||||||
|
|
||||||
|
## 1. Path segment encoding (crabidy-core)
|
||||||
|
|
||||||
|
- [x] Add the `percent-encoding` crate (workspace dependency; check the
|
||||||
|
nixpkgs/devenv side is unaffected — pure Rust). Implement
|
||||||
|
`encode_segment` (encode `/`, `%`, whitespace, controls and everything
|
||||||
|
non-alphanumeric-unreserved via an `AsciiSet`) and `decode_segment`
|
||||||
|
(lossy UTF-8 decode). **Verify**: the three `crabidy-core` tests pass
|
||||||
|
(`encode_segment_round_trips_arbitrary_text`,
|
||||||
|
`encoded_segments_are_path_safe`, `decode_segment_is_lossy_not_panicky`);
|
||||||
|
gates "encode/decode are total", "only percent-encoding added".
|
||||||
|
|
||||||
|
## 2. Verify the search payload shape (tidaldy, live API)
|
||||||
|
|
||||||
|
- [x] Before typing the models: run the existing explorer helper
|
||||||
|
(`Client::search`) once against the live API (needs the local tidal
|
||||||
|
config; if unavailable, consult the response shapes used by other tdl
|
||||||
|
clients and mark the gate as verified-by-proxy). Confirm whether
|
||||||
|
`search/tracks|artists|albums` return the same `Page<T>` item shapes as
|
||||||
|
the library endpoints; put any deviation into `tidaldy::models`.
|
||||||
|
**Verify**: gate "models verified against the live API".
|
||||||
|
|
||||||
|
## 3. Typed search requests (tidaldy)
|
||||||
|
|
||||||
|
- [x] Implement `search_tracks/search_artists/search_albums`: single
|
||||||
|
`make_request` with `query`, `limit=SEARCH_RESULT_LIMIT`, `offset=0`,
|
||||||
|
decoding a `Page<T>`; no pagination loop. **Verify**: gate "first page
|
||||||
|
only"; unit-testable only against live API (leave network test
|
||||||
|
`#[ignore]` like the existing one).
|
||||||
|
|
||||||
|
## 4. Search subtree in the provider (tidaldy)
|
||||||
|
|
||||||
|
- [x] `is_track_path`: include `TidalPath::SearchTrack`; `track_id_from_path`
|
||||||
|
already falls out (its match is on the parsed variant — extend it).
|
||||||
|
**Verify**: `search_track_paths_are_track_paths`,
|
||||||
|
`track_id_is_extracted_from_search_track_paths` pass.
|
||||||
|
- [x] `get_lib_node` `Search` arm: children = one entry per stored term
|
||||||
|
(path `join_path(path, encode_segment(term))`, title = raw term,
|
||||||
|
`is_queable: false`), node `is_creatable: true`. Do not require
|
||||||
|
`user_id` for search paths (move the `get_user_id` gate into the arms
|
||||||
|
that need it — search must work even if the user id is missing).
|
||||||
|
**Verify**: gate "search node lists terms"; ignored network test
|
||||||
|
extended by hand.
|
||||||
|
- [x] `get_lib_node` `SearchTerm` arm: decode the term; implicitly register
|
||||||
|
unknown terms (stale-client recovery); fetch the three categories
|
||||||
|
concurrently (`tokio::join!`); node = tracks from `search_tracks`
|
||||||
|
(`to_proto(path)`), children = artists (`Artist: <name>` →
|
||||||
|
`/tidal/artists/<id>`, queueable) then albums (`Album: <title>` →
|
||||||
|
`/tidal/artists/<artist-id>/<album-id>`, queueable), `is_queable:
|
||||||
|
false`, `is_creatable: false`. **Verify**: gates "term node" +
|
||||||
|
"canonical children"; behavior exercised end-to-end in task 9.
|
||||||
|
- [x] `create_lib_node`: trim title → `InvalidInput` if empty; parent must
|
||||||
|
parse to `TidalPath::Search` else `NotSupported`; store raw term
|
||||||
|
idempotently (no duplicates, lock not held across await); return
|
||||||
|
`self.get_lib_node(term_path)`. **Verify**: gate "create semantics";
|
||||||
|
add non-network unit tests: create with bad parent / empty title on an
|
||||||
|
offline client returns the right errors (no API call happens before
|
||||||
|
validation).
|
||||||
|
|
||||||
|
## 5. Server plumbing (crabidy-server)
|
||||||
|
|
||||||
|
- [x] `ProviderOrchestrator::create_lib_node`: `/tidal`-prefixed parent →
|
||||||
|
`tidal_client.create_lib_node`, anything else `NotSupported` (warn like
|
||||||
|
the other routes). **Verify**: gate "orchestrator routing".
|
||||||
|
- [x] `rpc.rs create_library_node`: bounded(1) rendezvous with
|
||||||
|
`ProviderCommand::CreateLibraryNode`; map errors `NotSupported` →
|
||||||
|
`failed_precondition`, `InvalidInput` → `invalid_argument`, rest →
|
||||||
|
`internal`. **Verify**: gate "error mapping"; `cargo check`.
|
||||||
|
|
||||||
|
## 6. TUI input overlay (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `App::dispatch(LibraryCreateNode)`: open
|
||||||
|
`InputState { parent_path: library.path(), buffer: "" }` only when
|
||||||
|
`library.is_creatable()`. **Verify**:
|
||||||
|
`create_node_only_opens_input_on_creatable_nodes`.
|
||||||
|
- [x] `App::handle_input_key`: Esc cancels; Enter trims + sends
|
||||||
|
`MessageFromUi::CreateNode` (empty → just close); Backspace pops;
|
||||||
|
`KeyCode::Char(c)` appends regardless of SHIFT; all else ignored.
|
||||||
|
**Verify**: `input_appends_and_backspace_pops`,
|
||||||
|
`esc_cancels_without_sending`, `enter_submits_trimmed_title_and_closes`,
|
||||||
|
`enter_on_empty_input_closes_without_sending`.
|
||||||
|
- [x] Event loop (`main.rs run_ui`): when `app.input.is_some()`, route the
|
||||||
|
key to `app.handle_input_key(key)` and skip `bindings::lookup`
|
||||||
|
entirely. **Verify**: gate "input mode bypasses bindings" (read the
|
||||||
|
loop; also confirm `q` cannot quit while typing).
|
||||||
|
|
||||||
|
## 7. TUI library rendering (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `Library::update`: apply empty nodes when `node.is_creatable` (keep the
|
||||||
|
skip for empty non-creatable nodes). **Verify**:
|
||||||
|
`empty_creatable_nodes_are_enterable`.
|
||||||
|
- [x] Mark creatable children in the list (suffix marker, `COLOR_SECONDARY`)
|
||||||
|
— requires keeping `is_creatable` on `UiItem`; hint in the pane title
|
||||||
|
when the open node is creatable (e.g. `search — % to add`).
|
||||||
|
**Verify**: gate "creatable marked in UI" (read render code).
|
||||||
|
- [x] Render the input overlay: bottom line of the library pane showing
|
||||||
|
`new node: <buffer>▏` while `input.is_some()`. **Verify**: gate "input
|
||||||
|
overlay renders"; add a `TestBackend` test asserting the buffer text
|
||||||
|
appears while open (mirror the help-modal test helpers).
|
||||||
|
|
||||||
|
## 8. TUI ↔ server wiring (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `RpcClient::create_library_node`: send request, evict
|
||||||
|
`library_node_cache` entry for `parent_path`, insert the returned node,
|
||||||
|
return it. **Verify**: gate "cache eviction" (read; the cache is
|
||||||
|
private — no test seam without refactoring, keep it a gate).
|
||||||
|
- [x] `main.rs poll`: `CreateNode` arm calls the client method; on success
|
||||||
|
`ReplaceLibraryNode(node)`; on failure log and leave the UI unchanged
|
||||||
|
(no panic — replace the stub's `todo!`). **Verify**: gate "create
|
||||||
|
failures surface as errors, never a panic".
|
||||||
|
|
||||||
|
## 9. End-to-end + gates sweep
|
||||||
|
|
||||||
|
- [x] Run the real stack (`crabidy-server` + `cbd-tui`, needs tidal login):
|
||||||
|
enter `/tidal/search`, `%`, type a term, Enter; results appear; queue a
|
||||||
|
track result; dive into an artist result. If no login is available,
|
||||||
|
exercise create/list/error paths against the offline client instead
|
||||||
|
and note it in the summary. **Verify**: architecture flow diagram
|
||||||
|
matches reality.
|
||||||
|
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests green,
|
||||||
|
every `quality/search.md` box checked, docs updated where behavior
|
||||||
|
shifted. Append the outcome + deviations to `plan/summary.md`.
|
||||||
|
|
@ -0,0 +1,806 @@
|
||||||
|
# Implementation summaries
|
||||||
|
|
||||||
|
## capture visibility + log redaction (2026-07-21, follow-up)
|
||||||
|
|
||||||
|
"Problems with capturing" turned out to be a display bug: the capture
|
||||||
|
had succeeded on disk, but the TUI's `RpcClient` caches every library
|
||||||
|
listing for the whole session, so a `/captures` (or `/queues`,
|
||||||
|
`/bookmarks`, `/fs`) listing visited once never showed later captures
|
||||||
|
or saved queues until a restart. Listings under those mutable roots
|
||||||
|
are now always refetched — they are cheap local directory walks on the
|
||||||
|
server — while remote provider nodes (tidal, youtube) keep the cache
|
||||||
|
that makes back-navigation instant (`is_cacheable`, unit-tested).
|
||||||
|
|
||||||
|
Found alongside in the same log: the player engine's `play` span
|
||||||
|
recorded the full stream URL — googlevideo `sig` tokens included — into
|
||||||
|
`cbd.log`. Sources are now logged as `scheme://host/…` only (local
|
||||||
|
paths verbatim; `display_source`, unit-tested). 181 workspace tests
|
||||||
|
green.
|
||||||
|
|
||||||
|
## youtube stream fetching (2026-07-21, follow-up)
|
||||||
|
|
||||||
|
The rustypipe swap fixed the decode problem but real playback then hit
|
||||||
|
YouTube's tokenless-fetch enforcement, measured live: every stream URL
|
||||||
|
serves exactly its leading 1 MiB (403 beyond — plain, open-ended, and
|
||||||
|
oversized requests are rejected outright, and fresh URLs refuse offset
|
||||||
|
starts, killing URL-per-window chaining). PO tokens would lift the cap,
|
||||||
|
but rustypipe only attaches them to web clients whose signature
|
||||||
|
deciphering is currently broken upstream (verified on git master;
|
||||||
|
`rustypipe-botguard` built and tested — ineffective through the iOS
|
||||||
|
client). `yt-dlp` still solves the ciphers; its URLs stream the whole
|
||||||
|
file at a throttled ~32 KB/s — double the audio bitrate.
|
||||||
|
|
||||||
|
Shipped: (1) **windowed HTTP fetching** everywhere — a
|
||||||
|
`WindowedHttpStream` `SourceStream` in audio-player (bounded ~1 MiB
|
||||||
|
ranges, 200-body fallback for range-ignoring servers, eager
|
||||||
|
seek/reconnect so rejected windows fail typed instead of retrying
|
||||||
|
forever, URLs never in errors) and the same windowing in the capture
|
||||||
|
downloader (strict-CDN test stitches windows byte-exact); (2) `yt-dlp`
|
||||||
|
back as a **stream-URL-only sidecar** — all metadata stays on
|
||||||
|
rustypipe; a missing binary degrades to 1 MiB streams with a warning,
|
||||||
|
a failing call falls back to the rustypipe URL; (3) `botguard_bin`
|
||||||
|
config passthrough so streams flip back to pure Rust when upstream
|
||||||
|
deciphering recovers; (4) capture per-track deadline raised to 30 min
|
||||||
|
for the throttle. Live-verified end to end on the exact track from the
|
||||||
|
user's log: sidecar URL in 3 s, windowed stream + rodio decode
|
||||||
|
producing samples at 4.3 s. 179 workspace tests green (12 new);
|
||||||
|
`quality/youtube-rustypipe.md` gained a checked "Stream fetching"
|
||||||
|
section.
|
||||||
|
|
||||||
|
## youtube-rustypipe (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/youtube-rustypipe.md`: the ytdy provider's `yt-dlp`
|
||||||
|
subprocess engine was replaced with the pure-Rust **rustypipe**
|
||||||
|
Innertube client, fixing broken playback along the way.
|
||||||
|
|
||||||
|
Root cause of "search works but nothing plays": `-f bestaudio` selects
|
||||||
|
WebM/**Opus**, and the player (rodio + symphonia) has no Opus decoder.
|
||||||
|
The new engine picks the highest-bitrate `audio/mp4` (AAC) stream,
|
||||||
|
which symphonia decodes — verified live end to end (rustypipe stream
|
||||||
|
URL → download → `rodio::Decoder` produces samples). Download captures
|
||||||
|
of YouTube tracks now get playable `.m4a` files too.
|
||||||
|
|
||||||
|
The alternatives the user suggested were live-tested first:
|
||||||
|
`rusty_ytdl` 0.7.4 searches fine but returns empty stream URLs (cipher
|
||||||
|
rotation outran it), `rustube` is unmaintained since ~2022,
|
||||||
|
`rust-yt-downloader` is a thin CLI. `rustypipe` 0.11.4 worked for
|
||||||
|
everything (see `architecture/youtube-rustypipe.md`).
|
||||||
|
|
||||||
|
Design: an `Extract` trait seam (search, video, audio stream URL,
|
||||||
|
saved playlists, playlist videos) with `RustyPipeExtractor` as the
|
||||||
|
real implementation — provider logic is tested against a programmable
|
||||||
|
fake (no network, no fake shell scripts). Login keeps the `cookies`
|
||||||
|
setting (Netscape export) via `user_auth_set_cookie_txt`, cache-first:
|
||||||
|
rustypipe refreshes and persists the rotated cookie under
|
||||||
|
`<config>/crabidy/rustypipe/`, so it outlives the stale export; any
|
||||||
|
login failure degrades to logged-out. Saved playlists replace the
|
||||||
|
never-validated `feed/playlists` scrape; playlist nodes page up to
|
||||||
|
1000 tracks. `ytdy.toml` loses `binary` (old keys tolerated),
|
||||||
|
`yt-dlp` left `devenv.nix`, ytdy no longer needs `tokio/process`.
|
||||||
|
|
||||||
|
Deviations: none from the new architecture doc; the original
|
||||||
|
`youtube-provider.md` engine decision (D1) is marked superseded.
|
||||||
|
Live probe (search → mp4 stream URL → 206 fetch → metadata) ran
|
||||||
|
against real YouTube and was removed after passing. 169 workspace
|
||||||
|
tests green (ytdy: 10 + 2 extractor tests, all offline); every gate in
|
||||||
|
`quality/youtube-rustypipe.md` checked.
|
||||||
|
|
||||||
|
## incremental-captures (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/incremental-captures.md`: download captures are now
|
||||||
|
**incremental and resumable**, uncapturable tracks are first-class
|
||||||
|
**skipped** entries, and captures stream **progress** to clients.
|
||||||
|
|
||||||
|
- **Skipped playable (fsdy + proto).** `[playable] skipped = true` is a
|
||||||
|
fourth, mutually exclusive playable; `Playable::Skipped`,
|
||||||
|
`from_track_skipped`, and a new wire flag `Track.is_skipped` (set by
|
||||||
|
`to_track`). `from_track` preserves skipped-ness, so persisted queues
|
||||||
|
and bookmarks keep the marking instead of degrading it into a dead
|
||||||
|
link. `get_urls_for_track` on a skipped file is a typed `FetchError`.
|
||||||
|
- **Incremental walk.** The shared capture walk now runs in two phases:
|
||||||
|
enumerate (dirs + tracks, caps enforced — the total is known before
|
||||||
|
the first download) then fetch. `Sink::Download` writes straight into
|
||||||
|
`captures/<name>` (no tmp/swap): satisfied entries — parseable toml,
|
||||||
|
non-skipped playable, audio present — are reused; skipped, broken, or
|
||||||
|
audio-less entries are re-captured; uncapturable sources (skipped
|
||||||
|
source track, unresolvable stream, non-http playable) are recorded as
|
||||||
|
skipped tomls instead of silently omitted; a real download failure
|
||||||
|
aborts the run but keeps everything written, so re-capturing the same
|
||||||
|
name resumes. The byte budget counts only bytes downloaded per run.
|
||||||
|
Bookmarks keep tmp-and-swap overwrite semantics unchanged.
|
||||||
|
- **Progress + accept-then-stream RPC.** New `CaptureProgress` update on
|
||||||
|
the stream (name, download, done/total/skipped, terminal
|
||||||
|
finished/error). `CaptureLibraryNode` replies once validation (name,
|
||||||
|
store, download blessing) passes; the walk runs detached and its
|
||||||
|
bounded progress channel is forwarded into the update broadcast. This
|
||||||
|
also unfreezes the TUI: its poll loop used to await the whole capture.
|
||||||
|
- **Playback.** `play` skips `is_skipped` tracks without a provider round
|
||||||
|
trip and bounds the whole skip loop to one full queue pass — an
|
||||||
|
all-skipped queue with repeat on now stops instead of hammering the
|
||||||
|
provider forever (pre-existing spin fixed).
|
||||||
|
- **TUI.** Skipped tracks render red in queue and library (playing-track
|
||||||
|
marker keeps precedence). A `CaptureBoard` renders progress lines at
|
||||||
|
the bottom of the library pane (`capturing faves 3/12 (1 skipped)`),
|
||||||
|
lingering 5 s on success and 10 s (red) on failure. The `W` help entry
|
||||||
|
and the capture input label warn that captures are slow and resumable.
|
||||||
|
Contrast fix: colored items (editable/marked/skipped/current) switch to
|
||||||
|
the dark foreground under the focused selection bar.
|
||||||
|
|
||||||
|
Deviations from the architecture doc: `tracks_done` counts skipped
|
||||||
|
entries too (the ratio must reach the total on success) — doc and proto
|
||||||
|
reconciled; the input-overlay warning was shortened to
|
||||||
|
"capture (slow, resumable)" to fit narrow panes. `taplo` reports a
|
||||||
|
pre-existing formatting issue in `.opencode/skills/skill-authoring/`
|
||||||
|
(not touched here). 167 workspace tests green (13 new);
|
||||||
|
every gate in `quality/incremental-captures.md` checked.
|
||||||
|
|
||||||
|
## cbd-bundle (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/cbd-bundle.md`: a new **`cbd`** binary bundles server
|
||||||
|
and TUI. Both former binaries became libraries with thin mains —
|
||||||
|
`crabidy_server::serve(addr)` is the extracted server startup
|
||||||
|
(orchestrator, queue store, playback, player forwarder, tonic), and
|
||||||
|
`cbd_tui::run(config)` the extracted client loops; the standalone
|
||||||
|
binaries behave exactly as before. `cbd` sets up one file-based tracing
|
||||||
|
subscriber for both halves (the terminal belongs to the TUI), spawns
|
||||||
|
`serve` on the fixed listen address, polls a TCP connect against the
|
||||||
|
TUI's configured server address until ready (bounded, generous — first
|
||||||
|
runs may sit in a provider login), then runs the TUI. An
|
||||||
|
already-running standalone server just gets adopted (the in-process
|
||||||
|
bind fails on the occupied port and is deliberately ignored once the
|
||||||
|
socket is reachable); a server that dies before readiness surfaces its
|
||||||
|
real error. Quitting the TUI ends the process and the in-process
|
||||||
|
server — the continuously persisted current queue makes that safe.
|
||||||
|
|
||||||
|
Deviations: none of substance — the refactor moved code verbatim
|
||||||
|
(`crabidy_server::` → `crate::` path rewrites aside). The live probe
|
||||||
|
booted the extracted stack on a free port through the same readiness
|
||||||
|
poll `cbd` uses: real tidal login, all providers, playback, queue
|
||||||
|
restore, TCP accept in ~1 s (probe removed after passing). 154
|
||||||
|
workspace tests green (2 new in `cbd`); every gate in
|
||||||
|
`quality/cbd-bundle.md` checked.
|
||||||
|
|
||||||
|
## captures follow-up: W on queues and bookmarks (2026-07-21)
|
||||||
|
|
||||||
|
Small fix on top of the captures feature: `/queues` and `/bookmarks`
|
||||||
|
nodes are now `W`-capturable. `fsdy::Client` gained
|
||||||
|
`with_downloadable_nodes()` (instance-wide `is_downloadable`, applied to
|
||||||
|
the queues and bookmarks mounts; `/fs` and `/captures` stay off), and
|
||||||
|
the download sink softens all-or-nothing for exactly one case: a track
|
||||||
|
whose source cannot be captured — stream resolution fails, or resolves
|
||||||
|
to a non-http(s) target like a local file playable — is skipped with a
|
||||||
|
warning instead of aborting, since queue/bookmark captures mix
|
||||||
|
providers. Real download failures (bad status, transport, timeout) stay
|
||||||
|
fatal. `architecture/captures.md` D3/D4 reconciled; new tests
|
||||||
|
`downloadable_instances_flag_every_node` (fsdy) and
|
||||||
|
`download_capture_skips_uncapturable_tracks` (capture store).
|
||||||
|
|
||||||
|
## youtube-provider (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/youtube-provider.md`: a new workspace crate **`ytdy`**
|
||||||
|
mounts YouTube at `/youtube`, backed by a `yt-dlp` subprocess (declared
|
||||||
|
in `devenv.nix`). All extraction goes through one `Engine` seam:
|
||||||
|
argv-only invocations with `--no-warnings`, an optional `--cookies`
|
||||||
|
flag, a per-call timeout (`kill_on_drop`), a 32 MiB stdout cap, and
|
||||||
|
typed `EngineError`s — tests drive the whole provider through a fake
|
||||||
|
shell-script binary, no network.
|
||||||
|
|
||||||
|
Search needs no login and mirrors tidal's search exactly: `%` on
|
||||||
|
`/youtube/search` creates an in-memory term (deduplicated, implicitly
|
||||||
|
recreated on stale paths, rename re-searches, delete idempotent), whose
|
||||||
|
node lists the top N (`ytsearchN:`, default 20) results as queueable,
|
||||||
|
downloadable tracks. With a readable cookies file configured in
|
||||||
|
`ytdy.toml` ("logged in"), a `playlists` subtree appears
|
||||||
|
(`feed/playlists` flat listing → playlist nodes with tracks); an
|
||||||
|
unreadable cookies file degrades to logged-out with a warning, never a
|
||||||
|
failed init. Streams resolve via `-f bestaudio/best -g`; captures work
|
||||||
|
end to end (`extension_for` gained `audio/webm → webm`). The
|
||||||
|
orchestrator wires `/youtube` non-fatally: a failed `--version` probe
|
||||||
|
disables the provider, nothing else.
|
||||||
|
|
||||||
|
Deviations: `Entry` keeps separate `uploader`/`channel` fields with an
|
||||||
|
`artist()` preference — the planned serde alias rejects real yt-dlp
|
||||||
|
output as a duplicate field (found by the live probe). The live probe
|
||||||
|
validated search, stream resolution, and a real download capture
|
||||||
|
(252 KB webm) through the capture store; the **playlists feed
|
||||||
|
invocation is live-unvalidated** (no cookies on this machine) — flagged
|
||||||
|
in `architecture/youtube-provider.md` as the standing risk. 150
|
||||||
|
workspace tests green (8 new in `ytdy`); every gate in
|
||||||
|
`quality/youtube-provider.md` checked.
|
||||||
|
|
||||||
|
## captures (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/captures.md`: `W` (shift) on a downloadable library
|
||||||
|
selection captures the subtree like a bookmark, but into
|
||||||
|
`<config>/crabidy/captures/<name>/` with every track's audio
|
||||||
|
**downloaded** next to its order-prefixed toml — the toml's playable is
|
||||||
|
the audio file's *relative* name (`TrackFile::from_track_with_file`), so
|
||||||
|
a capture plays with no provider round trip and the folder stays
|
||||||
|
relocatable. `/captures` is a fourth `fsdy` instance (editable top
|
||||||
|
level, nothing reserved): browse, queue, rename (`e`), delete (`d`),
|
||||||
|
and re-capture to refresh; the audio files are invisible to listings
|
||||||
|
(only dirs and `*.cbd-track.toml` count).
|
||||||
|
|
||||||
|
The bookmark walk was extracted into `capture.rs`
|
||||||
|
(`capture_into`/`write_tree`, `Caps`, one `CaptureError` for both
|
||||||
|
stores) parameterized by a per-track `Sink` — `Link` is byte-identical
|
||||||
|
bookmark behavior, `Download` fetches the first `get_urls_for_track`
|
||||||
|
URL through one shared reqwest client (30 s connect timeout, 600 s
|
||||||
|
per-track deadline, no retries), streams the body to disk against a
|
||||||
|
capture-wide byte budget, picks the extension from `Content-Type` (URL
|
||||||
|
path, then `bin`, as fallbacks), and writes the toml only after the
|
||||||
|
audio succeeded. Download caps: 1 000 dirs, 500 tracks, 4 GiB. Still
|
||||||
|
all-or-nothing with temp cleanup; downloads are sequential inside the
|
||||||
|
one spawned capture task. Download error messages carry the track's
|
||||||
|
library path, never the stream URL (`reqwest::Error::without_url`).
|
||||||
|
|
||||||
|
Nodes opt in via new additive proto flags
|
||||||
|
(`LibraryNode.is_downloadable = 8`, `LibraryNodeChild = 7`). Tidal sets
|
||||||
|
them centrally at the end of `get_lib_node`: downloadable = queueable
|
||||||
|
**or lists tracks** (so search-term track results are downloadable even
|
||||||
|
though the term node isn't queueable); children mirror `is_queable`;
|
||||||
|
tracks inherit their node's flag in the TUI. The server re-enforces at
|
||||||
|
the capture root (`Unsupported` → `failed_precondition`); the rpc
|
||||||
|
gained `CaptureLibraryNodeRequest.download = 3` (additive; old clients
|
||||||
|
keep bookmarking).
|
||||||
|
|
||||||
|
Deviations from the plan/architecture: the naming helper ended up
|
||||||
|
`audio_file_name` (the path variant was clippy-dead); the tidal flag
|
||||||
|
rule grew the "or lists tracks" clause (architecture D4 reconciled);
|
||||||
|
the live probe downloaded a single real track (8.6 MB m4a,
|
||||||
|
Content-Type-derived extension, replayed through a `/captures`
|
||||||
|
instance) instead of a whole album — the multi-track walk is
|
||||||
|
unit-covered and a full album download is needlessly heavy for a smoke
|
||||||
|
test. 142 workspace tests green (1 new in `fsdy`, 8 in
|
||||||
|
`capture`/`capture_store`, 3 TUI + 1 extended); every gate in
|
||||||
|
`quality/captures.md` checked.
|
||||||
|
|
||||||
|
## bookmarks (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/bookmarks.md`: `w` on a queueable library selection now
|
||||||
|
captures the whole subtree as a **bookmark** — a structure-preserving
|
||||||
|
snapshot under `<config>/crabidy/bookmarks/<name>/`, mounted read-only at
|
||||||
|
`/bookmarks` by a third `fsdy` instance. The capture runs on the
|
||||||
|
orchestrator (a spawned task walking `get_lib_node` iteratively across
|
||||||
|
any provider): every child node becomes an order-prefixed folder
|
||||||
|
(`fsdy::dir_name`, sharing the queue entries' sanitizer), every track an
|
||||||
|
order-prefixed link file, so the case-insensitive listing reproduces the
|
||||||
|
source order and replaying is plain fs-provider behavior. Caps (1 000
|
||||||
|
dirs / 20 000 tracks) abort cleanly with the temp folder removed; writes
|
||||||
|
are tmp-and-swap; re-capturing a name overwrites it. The wire gained one
|
||||||
|
additive rpc, `CaptureLibraryNode(path, name)` (invalid name/source →
|
||||||
|
`invalid_argument`, over-cap/disabled → `failed_precondition`). The TUI
|
||||||
|
opens the existing input overlay prefilled with the selection's title
|
||||||
|
(`bookmark`), gated on a queueable bare selection.
|
||||||
|
|
||||||
|
On top, `fsdy::Client` gained `with_editable_top_level(reserved)`:
|
||||||
|
editable instances mark their root's child folders
|
||||||
|
`is_editable`/`is_deletable` and implement rename (no-merge, validated
|
||||||
|
titles, returns the renamed node) and delete (idempotent, returns the
|
||||||
|
refreshed root). Applied to `/bookmarks` (nothing reserved) **and
|
||||||
|
`/queues`** (reserved: `current`) — saved queues are now renamable and
|
||||||
|
deletable through the existing `e`/`d` flows with zero TUI changes.
|
||||||
|
`/fs` stays immutable. All 130 workspace tests green (6 new in `fsdy`, 7
|
||||||
|
in `bookmark_store`, 2 TUI); every gate in `quality/bookmarks.md`
|
||||||
|
checked. A temporary live probe (removed after passing) captured a
|
||||||
|
19-track album from the live Tidal API, browsed it with editable flags,
|
||||||
|
renamed it, resolved it in order, and fetched a stream URL for a
|
||||||
|
captured link.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions
|
||||||
|
are recorded in `architecture/bookmarks.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (bookmarks)
|
||||||
|
|
||||||
|
- **Capture is all-or-nothing**: any provider or write failure mid-walk
|
||||||
|
aborts the whole capture (temp folder removed) instead of skipping the
|
||||||
|
failing subtree with a warning — a bookmark that *looks* complete must
|
||||||
|
*be* complete. The architecture only specified the unreadable-*root*
|
||||||
|
case; this extends it to every node.
|
||||||
|
- **Rename to the current name is a no-op success** (returns the node),
|
||||||
|
not a collision error — the target "exists" only because it is the
|
||||||
|
source.
|
||||||
|
- **Rename targets don't pass `disk_path`**: the new folder name is
|
||||||
|
validated by `validate_folder_name` (no separators, NUL, or leading
|
||||||
|
dots), which makes it a plain sibling name by construction; the
|
||||||
|
traversal gate still covers every client-supplied *path*.
|
||||||
|
- **`track_file_name` was refactored onto a shared `ordered_name`**
|
||||||
|
helper rather than duplicated for `dir_name` (planned as "shared
|
||||||
|
sanitizer", realized as one function).
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR`; no repo change.
|
||||||
|
|
||||||
|
## queue-persistence (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/queue-persistence.md`: queues now survive server restarts,
|
||||||
|
realized entirely on top of the fs provider. `fsdy::Client` became
|
||||||
|
instance-mountable (`Client::new(provider_root, disk_root)`); the
|
||||||
|
orchestrator mounts a second, read-only instance at `/queues` over
|
||||||
|
`<config>/crabidy/queues/`, so saved queues are ordinary browsable,
|
||||||
|
queueable library folders. Every queue is a folder of order-prefixed
|
||||||
|
(`0001 <title>.cbd-track.toml`) **link** files — metadata copied from the
|
||||||
|
queue entry, `playable.link = Track.path` — written only by the new
|
||||||
|
`crabidy_server::queue_store::QueueStore` (tmp-and-swap, hidden
|
||||||
|
`.queue-state.toml` sidecar for position/repeat/shuffle). The playback
|
||||||
|
loop feeds every queue-state change into a latest-wins `watch` channel; a
|
||||||
|
persister task debounces, skips unchanged snapshots, and rewrites
|
||||||
|
`queues/current/`. On startup the server restores tracks, position, and
|
||||||
|
modifiers from `current/` without ever starting playback. `w` on the TUI
|
||||||
|
queue pane opens the existing input overlay (`save queue`) and drives the
|
||||||
|
previously stubbed `SaveQueue` rpc (invalid name → `invalid_argument`,
|
||||||
|
empty queue/disabled persistence → `failed_precondition`). Reloading a
|
||||||
|
saved queue is just queueing `/queues/<name>` — the listing rewrites each
|
||||||
|
link back to its target, so zero new resolve mechanisms. All 116
|
||||||
|
workspace tests green (10 new in `fsdy`, 9 in `queue_store`, 5 playback,
|
||||||
|
3 TUI); every gate in `quality/queue-persistence.md` checked. A temporary
|
||||||
|
live probe (removed after passing) round-tripped a mixed queue — a track
|
||||||
|
fetched from the live Tidal API plus an fs url track — through persist,
|
||||||
|
reload, `/queues` listing, and the resolve walk, and the reloaded Tidal
|
||||||
|
path still yielded a stream URL.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions
|
||||||
|
are recorded in `architecture/queue-persistence.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (queue-persistence)
|
||||||
|
|
||||||
|
- **The "no links into `/fs`" rule was dropped** (fs-provider D3): queue
|
||||||
|
entries persist as links to whatever path the queue held, including
|
||||||
|
`/fs/...` tracks. Replaced by the one-hop argument —
|
||||||
|
`get_urls_for_track` never follows a link, so chains die at play time
|
||||||
|
and cycles cannot recurse. `architecture/fs-provider.md` reconciled.
|
||||||
|
- **`SaveQueueError` gained `Disabled` and `State` variants** beyond the
|
||||||
|
stub: `Disabled` (no usable queues directory) maps to
|
||||||
|
`failed_precondition` instead of masquerading as I/O; `State` covers
|
||||||
|
sidecar serialization.
|
||||||
|
- **The orchestrator mounts `/queues` independently of `QueueStore`**:
|
||||||
|
both derive the directory from `queue_store::queues_dir()`, so a
|
||||||
|
mount over a not-yet-created folder simply lists as missing until the
|
||||||
|
store (created in `main`) writes it. No plumbing between the two.
|
||||||
|
- **Shuffle order is not persisted** (documented in D3/D4 but worth
|
||||||
|
repeating): restoring `shuffle = true` reshuffles around the restored
|
||||||
|
current track.
|
||||||
|
- **The live probe needed no bespoke server run**: provider-layer clients
|
||||||
|
plus `QueueStore` cover the full D2/D5 story; the gRPC and TUI layers
|
||||||
|
above are unit-tested.
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR`; no repo change.
|
||||||
|
|
||||||
|
## fs-provider (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/fs-provider.md`: a second media provider (crate `fsdy`,
|
||||||
|
`/fs`) that walks one configured root directory and treats
|
||||||
|
`*.cbd-track.toml` files as serialized track nodes — metadata plus exactly
|
||||||
|
one playable reference: a local audio file (absolute or relative to the
|
||||||
|
track file), an http(s) URL, or a crabidy-internal link. The wire types
|
||||||
|
are unchanged (architecture D1): the only new datastructure is the
|
||||||
|
on-disk TOML schema. Link tracks rewrite `Track.path` to the target at
|
||||||
|
listing time (D2), so playback routes to the owning provider through the
|
||||||
|
orchestrator's existing prefix routing with zero new mechanisms; links
|
||||||
|
into `/fs` are rejected at parse time, making chains impossible.
|
||||||
|
Directories list sorted and queue via the default chunked resolve walk;
|
||||||
|
client paths are decoded and validated in a single helper so they cannot
|
||||||
|
escape the root; symlinks, hidden entries, and broken files are skipped
|
||||||
|
with warnings. `ProviderOrchestrator` gained an optional fs client
|
||||||
|
(non-fatal init from `fsdy.toml`, default root `dirs::audio_dir()`) and
|
||||||
|
`/fs` routing arms in every trait method. No player or TUI changes were
|
||||||
|
needed. All 91 workspace tests green (15 new in `fsdy`); every gate in
|
||||||
|
`quality/fs-provider.md` checked. A temporary live probe (removed after
|
||||||
|
passing) built a real tree whose link track pointed at a track fetched
|
||||||
|
from the live Tidal API: listing order held, the link path was
|
||||||
|
rewritten, and the target resolved a stream URL — the full D2 story
|
||||||
|
end-to-end.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions
|
||||||
|
are recorded in `architecture/fs-provider.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (fs-provider)
|
||||||
|
|
||||||
|
- **Extension renamed to `.cbd-track.toml`** (user request, follow-up
|
||||||
|
commit): the original `.track.toml` was too generic; the `cbd-` prefix
|
||||||
|
makes the files unmistakably crabidy's.
|
||||||
|
- **`TrackFileError::UrlScheme` carries only the scheme**, not the URL:
|
||||||
|
the parse error ends up in skip-warnings, and a private stream URL may
|
||||||
|
embed a token (quality gate "no file contents in logs"). The
|
||||||
|
architecture's schema and behavior are otherwise as designed.
|
||||||
|
- **The live probe ran at the provider layer**, not against a running
|
||||||
|
server (no interactive terminal/audio device here, same as previous
|
||||||
|
features): `fsdy` and `tidaldy` clients driven directly, mimicking the
|
||||||
|
orchestrator's routing exactly. It also had to *fetch* its link target
|
||||||
|
first — the well-known id from the progressive-queueing probe is an
|
||||||
|
album path, and a link must point at a track.
|
||||||
|
- **`get_lib_node` on a track path is `MalformedPath`** — implicit in
|
||||||
|
the design, made explicit so the default resolve walk can never
|
||||||
|
mistake a track file for a directory.
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo
|
||||||
|
change.
|
||||||
|
|
||||||
|
## progressive-queueing (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/progressive-queueing.md`: queueing a large nested collection
|
||||||
|
now fills the queue progressively instead of freezing until the full
|
||||||
|
resolve. `ProviderClient` gained `resolve_tracks_into` (chunk-streaming over
|
||||||
|
a bounded channel; sender-drop = done, receiver-drop = cancel) with a
|
||||||
|
default pre-order walk; tidaldy overrides it so playlists and albums emit
|
||||||
|
one chunk per fetched 50-track page. The playback loop registers a pending
|
||||||
|
op per queue command, spawns a forwarder, applies chunks on the loop
|
||||||
|
(single-writer preserved), broadcasts after every chunk, and starts playback
|
||||||
|
with the first chunk that makes a track current. `Replace`/`Clear` cancel
|
||||||
|
in-flight resolves down to the HTTP fetch. The wire gained
|
||||||
|
`Queue.resolving = 4` (additive); the TUI renders an animated one-to-three
|
||||||
|
dots pseudo-item after the last queue row while it is set. All 76 workspace
|
||||||
|
tests green; every gate in `quality/progressive-queueing.md` checked.
|
||||||
|
Verified against the live Tidal API with a temporary ignored probe (removed
|
||||||
|
after passing): a 71-album artist streamed its first 19-track chunk (first
|
||||||
|
album, listing order) while the walk was still running, and dropping the
|
||||||
|
receiver mid-stream ended the resolve cleanly in 1.8 s instead of draining
|
||||||
|
the discography.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions are
|
||||||
|
recorded in `architecture/progressive-queueing.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (progressive-queueing)
|
||||||
|
|
||||||
|
- **`make_paginated_request_into` became `stream_track_pages_into`**: the
|
||||||
|
planned generic `AsyncFnMut` page sink dies on a rustc
|
||||||
|
"implementation of `Send` is not general enough" limitation inside
|
||||||
|
`async_trait` methods. The concrete method (fixed `Track` item type,
|
||||||
|
proto mapping and channel send inlined) sidesteps it with the same
|
||||||
|
page-loop and cancellation semantics.
|
||||||
|
- **Zero-track warning lives in the forwarder, not `finish_resolve`**: the
|
||||||
|
forwarder sees each path and its chunk count, so the existing per-path
|
||||||
|
"resolved to no playable tracks" message survives verbatim; the planned
|
||||||
|
op-level warning would have had to smuggle paths into `PendingResolve`.
|
||||||
|
- **Fixed alongside (user-reported)**: Enter on a non-queueable library
|
||||||
|
item used to blank the queue while audio kept playing. Two causes, both
|
||||||
|
fixed: `Library::get_selected` now gates the bare selection on
|
||||||
|
`is_queable` (marks were already gated), and a replace that resolves to
|
||||||
|
zero tracks no longer touches the queue at all — structurally, since the
|
||||||
|
queue is only mutated by arriving chunks. Regression test
|
||||||
|
`queue_ops_ignore_non_queueable_selections`.
|
||||||
|
- **`Queue` (play-next) captures the current position when the command
|
||||||
|
arrives**, not per chunk: chunks of one op stay contiguous after the
|
||||||
|
track the user was on when they pressed the key, even if playback
|
||||||
|
advances mid-resolve.
|
||||||
|
- **Live probe scope**: the first full-discography probe was cut short
|
||||||
|
(hundreds of album fetches for no extra signal) and replaced by a
|
||||||
|
receive-two-chunks-then-cancel probe — which also exercises mid-stream
|
||||||
|
cancellation against the live API, which the drain-everything version
|
||||||
|
could not.
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo change.
|
||||||
|
|
||||||
|
## node-editing (2026-07-20)
|
||||||
|
|
||||||
|
Built per `plan/node-editing.md`: search-term nodes (created via `%`) are now
|
||||||
|
modifiable — `e` opens the input overlay prefilled with the current title and
|
||||||
|
renames (re-running the search; merge on title collision), `d` deletes
|
||||||
|
without confirmation (documented decision, architecture/node-editing.md D4).
|
||||||
|
Capabilities travel as `LibraryNodeChild.is_editable`/`is_deletable` (fields
|
||||||
|
5/6, child-only — no consumer for node-level copies), surfaced as a `[ed]`
|
||||||
|
marker; two new rpcs `RenameLibraryNode` (returns the renamed node, TUI
|
||||||
|
navigates into it) and `DeleteLibraryNode` (returns the refreshed parent).
|
||||||
|
All 59 workspace tests green; every gate in `quality/node-editing.md`
|
||||||
|
checked. Verified against the live Tidal API with a temporary ignored probe
|
||||||
|
(removed after passing): create `beatles` → rename to `rolling stones`
|
||||||
|
(in-place, 20 tracks / 40 children) → a track queued under the old term
|
||||||
|
still resolved a stream URL → delete emptied the listing.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions are
|
||||||
|
recorded in `architecture/node-editing.md` (options + rationale per topic).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (node-editing)
|
||||||
|
|
||||||
|
- **Self-rename bug caught by the gate tests**: the first
|
||||||
|
`rename_search_term` implementation deleted a term renamed to itself (the
|
||||||
|
merge branch removed the "old" slot). Fixed with an explicit `old != new`
|
||||||
|
guard; the architecture text ("merge on collision") now implicitly means
|
||||||
|
*distinct* titles.
|
||||||
|
- **`pane_bindings_only_match_their_own_pane` (help-modal suite) updated**:
|
||||||
|
it asserted plain `d` is unbound in the library — now it is
|
||||||
|
`LibraryDeleteNode` by design; the test's queue-only example key moved to
|
||||||
|
`c`.
|
||||||
|
- **`delete_library_node` also maps `InvalidInput` → `invalid_argument`**
|
||||||
|
although no provider raises it for delete today — keeps the error contract
|
||||||
|
uniform across the three node-mutation rpcs.
|
||||||
|
- **End-to-end check ran at the provider layer** (as with search): no
|
||||||
|
interactive terminal/audio device in this environment; the gRPC handler
|
||||||
|
and TUI layers above it are covered by unit tests and review.
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo change.
|
||||||
|
|
||||||
|
## search (2026-07-20)
|
||||||
|
|
||||||
|
Built per `plan/search.md`: `%` inside `/tidal/search` opens a one-line input;
|
||||||
|
the term becomes a persistent (per-process) tree node holding Tidal search
|
||||||
|
results — 20 tracks queueable in place, plus artist/album results as canonical
|
||||||
|
`/tidal/artists/...` children. Creatable nodes carry an `is_creatable` flag
|
||||||
|
end-to-end (proto → provider → TUI marker `[%]` + pane hint). All 48 workspace
|
||||||
|
tests green; every gate in `quality/search.md` checked. Verified against the
|
||||||
|
live Tidal API: payload shapes match the existing models (probe kept as the
|
||||||
|
ignored `probe_search_shapes` test), and a full create→list→resolve-URL round
|
||||||
|
trip succeeded (`beatles` → 20 tracks / 40 children, idempotent, playable
|
||||||
|
stream URL from a search-track path).
|
||||||
|
|
||||||
|
The whole feature ran autonomously on user instruction; decisions were taken
|
||||||
|
without mid-stage confirmation and recorded in `architecture/search.md`
|
||||||
|
(options + decision per topic).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (search)
|
||||||
|
|
||||||
|
- **`Library::update` now concatenates tracks and children** (tracks first).
|
||||||
|
The old code showed tracks *instead of* children, which would have hidden
|
||||||
|
the artist/album results on term nodes — architecture assumed both would
|
||||||
|
render. Existing nodes are unaffected (they only ever carry one kind).
|
||||||
|
- **Search categories degrade independently**: a failing category logs and
|
||||||
|
contributes nothing; only all three failing is a `FetchError`. The plan
|
||||||
|
did not specify partial-failure behavior.
|
||||||
|
- **`get_lib_node` no longer requires a user id up front** — the gate moved
|
||||||
|
into the favorites arms (planned), which also means `create_lib_node`
|
||||||
|
validation works fully offline (used by the new unit tests).
|
||||||
|
- **End-to-end check ran at the provider layer** (temporary ignored test,
|
||||||
|
removed after passing) rather than driving the full TUI + server — no
|
||||||
|
interactive terminal/audio device in this environment. The gRPC handler and
|
||||||
|
TUI layers above it are covered by unit tests and review.
|
||||||
|
- **Environment note**: `target/` contains owner-built artifacts not writable
|
||||||
|
by this agent's user; builds/tests ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR`. No repo change involved.
|
||||||
|
|
||||||
|
## help-modal (2026-07-20)
|
||||||
|
|
||||||
|
Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
|
||||||
|
`BINDINGS` table + `lookup` + `key_label`), `app/help.rs` (overlay), the
|
||||||
|
`App::dispatch`/`DispatchResult` seam, and the rewired event loop in
|
||||||
|
`main.rs`. All 20 tests pass; every gate in `quality/help-modal.md` checked.
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (help-modal)
|
||||||
|
|
||||||
|
- **Two-column modal layout.** The architecture assumed a single-column list;
|
||||||
|
the full table is ~50 rows and would not fit even a 100×40 frame. The modal
|
||||||
|
renders Global in the left column and Library + Queue stacked in the right
|
||||||
|
column, with the close keys as a footer line (`Close help: ?, Esc, q`)
|
||||||
|
derived from the `Scope::Help` bindings instead of a fourth listed group.
|
||||||
|
The open question "scroll vs truncate" stays resolved as truncate — but
|
||||||
|
after the column split the content fits ~34×94, so truncation only kicks in
|
||||||
|
on genuinely small terminals.
|
||||||
|
- **`Scope` derives `Hash`** (not in the stub) so the chord-uniqueness test
|
||||||
|
can use a `HashSet`.
|
||||||
|
- **`QueueInsertHere` description reworded** to "Insert library selection
|
||||||
|
after this track": `crabidy-server`'s `insert_tracks` splices at
|
||||||
|
`position + 1`. Same check confirmed the planned "Queue selection after
|
||||||
|
current track" wording for `LibraryQueueNext`.
|
||||||
|
- **`main.rs`** passes `tx` to `App::new` without the now-unneeded clone; the
|
||||||
|
`KeyCode`/`KeyModifiers`/`UiFocus`/`StatefulList` imports moved out with the
|
||||||
|
old match.
|
||||||
|
|
||||||
|
## capture-deletion (2026-07-21)
|
||||||
|
|
||||||
|
Deletes under `/captures` now work at any depth and remove data from
|
||||||
|
disk, behind a TUI confirmation (`architecture/capture-deletion.md`;
|
||||||
|
direct implementation, no separate plan file — the change is four
|
||||||
|
bounded seams):
|
||||||
|
|
||||||
|
- **Proto**: `LibraryNode.tracks_deletable` (field 9) — node-level
|
||||||
|
"listed tracks may be deleted", mirroring the `is_downloadable`
|
||||||
|
inheritance so no `Track` literal anywhere had to change.
|
||||||
|
`DeleteLibraryNode` doc extended to tracks and recursive folders.
|
||||||
|
- **fsdy**: `with_deletable_tree()` (only the `/captures` instance sets
|
||||||
|
it): nested folders delete recursively; track deletes remove the toml
|
||||||
|
plus its `[playable] file` audio **iff** the canonicalized audio path
|
||||||
|
stays inside the canonicalized instance root (`..`/symlink-proof);
|
||||||
|
reserved names and the instance root remain undeletable; everything
|
||||||
|
idempotent. Deletes now return the actual parent listing (was: root —
|
||||||
|
identical for the previously-only-possible top-level case).
|
||||||
|
- **crabidy-server**: captures fsdy instance gains the flag; the
|
||||||
|
delete RPC path was already generic.
|
||||||
|
- **cbd-tui**: tracks inherit `is_deletable` from `tracks_deletable`;
|
||||||
|
`d` under `/captures` opens a modal red `delete <title>? [y/N]` line
|
||||||
|
(only `y`/`Y` sends, any other key cancels) — other deletables stay
|
||||||
|
unconfirmed by design; `selected_deletable()` now returns
|
||||||
|
`(path, title)`.
|
||||||
|
|
||||||
|
Tests: 4 new fsdy tests (flags, recursive delete, track+audio delete,
|
||||||
|
outside-root audio kept) and 3 new TUI tests (confirm-then-send,
|
||||||
|
cancel-on-anything-else, prompt render) plus a guard in the existing
|
||||||
|
queues delete test that track deletion stays `NotSupported` there.
|
||||||
|
188 workspace tests green; clippy `-D warnings` and fmt clean.
|
||||||
|
|
||||||
|
## roles-auth (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/roles-auth.md` from `architecture/roles-auth.md`:
|
||||||
|
basic-auth role authorization (owner / queue-owner / queue-appender)
|
||||||
|
with PHC password hashes in the new `crabidy-server.toml`.
|
||||||
|
|
||||||
|
- `crabidy-server/src/settings.rs` — `[auth]` loading; missing file =
|
||||||
|
open mode, malformed file = startup abort (fail-closed).
|
||||||
|
- `crabidy-server/src/auth.rs` — ordered `Role`, `minimum_role`
|
||||||
|
default-deny method table (pinned by a 24-method test),
|
||||||
|
`Authenticator` (argon2 verify, success-only credential cache,
|
||||||
|
indistinguishable failures), `AuthLayer`/`AuthService` tower layer
|
||||||
|
answering trailers-only `UNAUTHENTICATED`/`PERMISSION_DENIED` via
|
||||||
|
`Status::into_http()`, and `hash_password` for the new
|
||||||
|
`crabidy-server hash-password` subcommand (clap, stdin → PHC).
|
||||||
|
- `cbd-tui` — `user`/`password` config options and flags;
|
||||||
|
`AuthInterceptor` baking the Basic header into every request via
|
||||||
|
`CrabidyServiceClient::with_interceptor`.
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (roles-auth)
|
||||||
|
|
||||||
|
- None functionally. The dev-flow stages were compressed into one
|
||||||
|
autonomous pass (per standing instruction): stubs went straight to
|
||||||
|
implementation; `quality/roles-auth.md` gates were verified after
|
||||||
|
the fact and all hold.
|
||||||
|
- Denied-action UX in the TUI stays a logged no-op, as recorded in the
|
||||||
|
architecture's open questions.
|
||||||
|
|
||||||
|
## web-client (2026-07-21)
|
||||||
|
|
||||||
|
A Leptos/WASM browser client with TUI feature parity, served by
|
||||||
|
crabidy-server itself. Full dev-flow run: `architecture/web-client.md`,
|
||||||
|
`quality/web-client.md`, `plan/web-client.md`.
|
||||||
|
|
||||||
|
New workspace member **cbd-web** (CSR Leptos):
|
||||||
|
|
||||||
|
- `state.rs` / `keymap.rs` — the TUI's pane logic and bindings ported
|
||||||
|
as pure, DOM-free modules with native `#[test]`s (18 tests). Same
|
||||||
|
semantics: tracks-before-children, cursor memory, marks-win, capture
|
||||||
|
progress lines, `is_cacheable`, capture-delete confirmation rule.
|
||||||
|
- `rpc.rs` — gRPC-web (`tonic-web-wasm-client`) over the same
|
||||||
|
`crabidy-core` generated client and types as the TUI, with the same
|
||||||
|
basic-auth header interceptor.
|
||||||
|
- `app.rs` — one signal store fed by the update stream (reconnecting
|
||||||
|
backoff), one dispatcher mirroring the TUI dispatch, thin components:
|
||||||
|
library/queue panes, transport bar, name/confirm/login/help dialogs,
|
||||||
|
global keyboard wiring.
|
||||||
|
- `style.css` — pure modern CSS, single `--accent` crab orange-red with
|
||||||
|
`color-mix` derivations, light/dark via `color-scheme`+`light-dark()`
|
||||||
|
plus a persisted toggle, phone breakpoint.
|
||||||
|
|
||||||
|
crabidy-server changes:
|
||||||
|
|
||||||
|
- `web-ui` cargo feature (**default on**); `--no-default-features` =
|
||||||
|
headless gRPC-only.
|
||||||
|
- `build.rs` stages `cbd-web/dist` into `OUT_DIR` (or a placeholder
|
||||||
|
page — plain `cargo build` needs no wasm toolchain), embedded via
|
||||||
|
`include_dir`.
|
||||||
|
- `web.rs` serves the embedded bundle (GET/HEAD, index fallback).
|
||||||
|
- `serve()` refactored to `build_router()`: one axum router with the
|
||||||
|
gRPC service (auth layer → `tonic-web` GrpcWebLayer → service) as a
|
||||||
|
route and the web bundle as fallback; `axum::serve` replaces
|
||||||
|
`tonic::transport::Server`.
|
||||||
|
|
||||||
|
Cross-cutting:
|
||||||
|
|
||||||
|
- crabidy-core builds for `wasm32-unknown-unknown`: workspace `tonic`
|
||||||
|
set `default-features = false`, this crate takes codegen-only, native
|
||||||
|
binaries re-enable transport/router/channel; `build.rs` uses
|
||||||
|
`build_transport(false)`; config loading gated to non-wasm.
|
||||||
|
- devenv: `trunk`, `wasm-bindgen-cli`, `binaryen`, the wasm target, and
|
||||||
|
`build-web`/`serve-web` scripts (which clear `RUSTFLAGS` — the mold
|
||||||
|
linker flag breaks `rust-lld`).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (web-client)
|
||||||
|
|
||||||
|
- **No CRDT / local-first sync layer** (the example template's
|
||||||
|
automerge/loro): this app is a remote control for one live server
|
||||||
|
state, so "local first" was scoped to CSR + no-CDN assets + in-memory
|
||||||
|
caching + localStorage prefs + reconnect. Recorded in the
|
||||||
|
architecture doc up front.
|
||||||
|
- **`build_router()` extracted** from `serve()` (not in the plan) so
|
||||||
|
the three-way routing + auth composition is testable without a live
|
||||||
|
provider backend (`tests/web_server.rs`, incl. a native-gRPC-over-
|
||||||
|
axum h2c check).
|
||||||
|
- **Native dead-code allow** on the cbd-web binary target: the pure
|
||||||
|
modules are used by wasm + tests, not the native stub binary.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
202→ tests green across the workspace plus 4 new server routing tests
|
||||||
|
and 18 cbd-web logic tests; native and wasm clippy `-D warnings` clean;
|
||||||
|
fmt + markdownlint clean. Live smoke test (server with the real
|
||||||
|
embedded bundle + stubbed Tidal): `/` serves the shell, the 1.77 MB
|
||||||
|
wasm/js/css assets serve with correct content-types, deep links fall
|
||||||
|
back to the shell, unauthenticated and wrong-role gRPC-web calls return
|
||||||
|
UNAUTHENTICATED, and a native tonic client round-trips over axum.
|
||||||
|
|
||||||
|
## tui-search (2026-07-21)
|
||||||
|
|
||||||
|
`/` in the library or queue pane opens a live substring filter
|
||||||
|
(`architecture/tui-search.md`). Implemented directly (small feature).
|
||||||
|
|
||||||
|
- New `Filter` helper in `cbd-tui/src/app/list.rs`: keeps the pane's
|
||||||
|
full list, records visible real indices, maps view↔real. Both panes
|
||||||
|
route selection, marks, rendering, and `StatefulList` size through
|
||||||
|
it, so all movement keys work on the filtered view unchanged and the
|
||||||
|
queue's server-facing positions (remove/set-current) map back to real
|
||||||
|
indices.
|
||||||
|
- App gains a modal `search: Option<SearchState>` and
|
||||||
|
`handle_search_key` (type = live filter, Enter keeps, Esc clears),
|
||||||
|
an `OpenSearch` action bound to `/` in both pane scopes targeting the
|
||||||
|
focused pane, and event-loop routing ahead of the input overlay.
|
||||||
|
- Library resets search on node change; queue preserves it across the
|
||||||
|
frequent stream updates.
|
||||||
|
|
||||||
|
Tests: 2 queue tests (real-position mapping on removal, filter survives
|
||||||
|
updates) + 3 app tests (library filter/Enter/Esc lifecycle, focus
|
||||||
|
targeting, dive-clears-filter). 67 cbd-tui tests green; clippy clean.
|
||||||
|
|
||||||
|
Scope: TUI only, per the request; web-client parity noted as a
|
||||||
|
follow-up.
|
||||||
|
|
||||||
|
## client-configs (2026-07-21)
|
||||||
|
|
||||||
|
`cbd` now reads its own `cbd.toml` instead of sharing `cbd-tui.toml`
|
||||||
|
(`architecture/client-configs.md`, resolving the open risk in
|
||||||
|
`architecture/cbd-bundle.md`). One-line change in `cbd/src/main.rs`
|
||||||
|
(`init_config("cbd.toml")`); same `ServerConfig` type and localhost
|
||||||
|
default, so `cbd` (local, self-contained) and a remote-pointed
|
||||||
|
`cbd-tui` coexist on one machine without their `address` settings
|
||||||
|
colliding. README config table + client-config section updated.
|
||||||
|
|
||||||
|
## spectrum (2026-07-21)
|
||||||
|
|
||||||
|
A frequency-spectrum bar row under the track progress
|
||||||
|
(`architecture/spectrum.md`). Because the audio plays on the server and
|
||||||
|
clients may be remote, the spectrum is produced server-side and
|
||||||
|
streamed — a local loopback capture (BeSpec's model) could not serve a
|
||||||
|
remote `cbd-tui`.
|
||||||
|
|
||||||
|
- **audio-player**: `SpectrumTap` (a fixed lock-free ring of 2048 `f32`
|
||||||
|
slots, atomic write index doubling as an idle counter) and
|
||||||
|
`TappingSource`, which wraps the decoded rodio source and mirrors
|
||||||
|
each played frame (downmixed to mono) into the tap on the audio
|
||||||
|
thread — one store per sample, no locks/alloc/logging; `try_seek`
|
||||||
|
delegated so seeking still works. Exposed via `Player::spectrum_tap`.
|
||||||
|
- **crabidy-server**: `spectrum::SpectrumAnalyzer` (Hann window +
|
||||||
|
realfft, log-spaced bins, dBFS→[0,1]) and a ~20 fps task that skips
|
||||||
|
when no stream subscribers, snapshots the tap, and broadcasts a new
|
||||||
|
`StreamUpdate::Spectrum(SpectrumFrame{bins})`; it diffs the tap's
|
||||||
|
frame counter to emit a single zero frame on going idle (bars fall,
|
||||||
|
don't freeze) without touching the player command path.
|
||||||
|
- **proto**: `SpectrumFrame` + oneof field 9 on `GetUpdateStream`.
|
||||||
|
- **cbd-tui**: a bar row (block glyphs `▁..█`, accent color) under the
|
||||||
|
progress gauge in the now-playing pane; `spectrum` config option
|
||||||
|
(default true) on both `cbd-tui.toml` and `cbd.toml`.
|
||||||
|
- **cbd-web**: the same bins rendered as CSS-height accent bars
|
||||||
|
(parity).
|
||||||
|
|
||||||
|
Tests: 2 tap tests (downmix + snapshot ordering), 4 DSP tests (silence,
|
||||||
|
tone concentrates in one region + stays normalized, wrong-length is
|
||||||
|
zero-not-panic, monotonic bin edges), 3 TUI render tests (glyph
|
||||||
|
mapping, bars shown when enabled, hidden when disabled). All workspace
|
||||||
|
tests green; clippy (native + wasm) and fmt clean.
|
||||||
|
|
||||||
|
**Not exercised**: the end-to-end audio→FFT→stream path needs a real
|
||||||
|
audio output device, unavailable in this headless environment. The
|
||||||
|
components are unit-tested and the wiring compiles and starts; the live
|
||||||
|
path should be sanity-checked on a machine with audio.
|
||||||
|
|
||||||
|
## capture-local-files + queue-W (2026-07-22)
|
||||||
|
|
||||||
|
Two capture fixes (the first two of a set; store relocation and a
|
||||||
|
central dedup store come as a later refactor).
|
||||||
|
|
||||||
|
- **Capturing local playables (#4, `capture.rs`)**: a download capture
|
||||||
|
used to record any non-http(s) playable as *skipped* — so capturing
|
||||||
|
an fs node or a queue mixing streamed and local tracks produced red,
|
||||||
|
audioless entries even though the audio was on disk. `fetch_track`
|
||||||
|
now routes a local-file source to a new `copy_local`, which copies the
|
||||||
|
file in next to its toml (source extension preserved, counted against
|
||||||
|
the run's byte budget); a missing/unreadable source still records
|
||||||
|
skipped. Rewrote the mixed-queue test to assert copy-not-skip and
|
||||||
|
added a direct local-copy test.
|
||||||
|
- **`W` on the queue (#3, `cbd-tui`)**: the queue pane bound only `w`
|
||||||
|
(save); capturing the queue meant save-then-navigate-then-`W`. Added
|
||||||
|
`QueueDownloadCapture` on Shift-`W` in the queue scope, which
|
||||||
|
download-captures `/queues/current` (the continuously persisted live
|
||||||
|
queue) via the usual name dialog. Bound + dispatch + tests.
|
||||||
|
|
||||||
|
Docs: architecture/incremental-captures.md D2 updated (local files
|
||||||
|
copied, not skipped); root README capture section updated (queue `W`,
|
||||||
|
local-copy behavior).
|
||||||
|
|
||||||
|
Deferred to the refactor: moving queues/bookmarks/captures out of
|
||||||
|
`.config` into `.local/state` (with migration), and a central
|
||||||
|
content-addressed audio store so captures dedup and link instead of
|
||||||
|
copy.
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Plan — web client
|
||||||
|
|
||||||
|
From `architecture/web-client.md` and `quality/web-client.md`.
|
||||||
|
|
||||||
|
## Toolchain & skeleton (done during api-design)
|
||||||
|
|
||||||
|
- [x] devenv: trunk, wasm-bindgen-cli, binaryen, wasm32 target;
|
||||||
|
`build-web`/`serve-web` scripts (RUSTFLAGS cleared for rust-lld).
|
||||||
|
- [x] crabidy-core on wasm: tonic codegen-only (workspace tonic
|
||||||
|
default-features=false, members re-enable), `build_transport(false)`,
|
||||||
|
native-only config gated. Verify: `cargo check -p crabidy-core
|
||||||
|
--target wasm32-unknown-unknown`.
|
||||||
|
- [x] cbd-web crate: Trunk.toml (+ dev proxy), index.html, style.css
|
||||||
|
skeleton, `state.rs` (pane logic ports + tests), `keymap.rs`
|
||||||
|
(TUI bindings port + tests), `rpc.rs` (gRPC-web client + basic
|
||||||
|
auth header), app shell. Verify: wasm check + native tests +
|
||||||
|
`trunk build --release`.
|
||||||
|
- [x] crabidy-server: `web-ui` feature (default on), build.rs staging
|
||||||
|
(dist or placeholder), `web.rs` static fallback (+tests), serve()
|
||||||
|
on one axum router: auth → grpc-web → service, assets public.
|
||||||
|
Verify: check with/without feature, tests.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- [x] **Stream task**: connect `GetUpdateStream` on startup, apply
|
||||||
|
updates to signals (queue, mods, play state, volume, mute,
|
||||||
|
position, capture board), reconnect with capped backoff +
|
||||||
|
`connected` signal. Gate: parity/stream.
|
||||||
|
- [x] **Library pane**: listing from `LibraryPane` state, click =
|
||||||
|
select, double-click/`l` = dive, breadcrumb/`h` = ascend, marks,
|
||||||
|
capability badges (`%`/`[e]`/`[d]` equivalents), skipped red.
|
||||||
|
Library cache honoring `is_cacheable`. Gate: parity/semantics.
|
||||||
|
- [x] **Queue pane**: track list with current highlight + resolving
|
||||||
|
indicator, select/play/remove/clear/save, insert-here from
|
||||||
|
library selection. Gate: parity.
|
||||||
|
- [x] **Transport bar**: play/pause, prev/next, restart, stop-aware
|
||||||
|
play state, volume slider + mute, shuffle/repeat toggles,
|
||||||
|
progress gauge from `TrackPosition`. Gate: parity.
|
||||||
|
- [x] **Dialogs**: name input (create/rename/save-queue/capture with
|
||||||
|
slow-warning label), capture-delete y/N (red), help overlay
|
||||||
|
(`?`), login form on `UNAUTHENTICATED` (localStorage-backed).
|
||||||
|
All modal: keys bypass the keymap. Gate: parity + security.
|
||||||
|
- [x] **Keyboard wiring**: global keydown listener → `keymap::lookup`
|
||||||
|
→ actions; input elements exempt (typing in dialogs). Gate:
|
||||||
|
parity.
|
||||||
|
- [x] **Capture progress lines**: board fed by stream, rendered at the
|
||||||
|
library pane bottom, errors red, linger semantics from state.rs.
|
||||||
|
Gate: parity.
|
||||||
|
- [x] **CSS**: full styling — layout grid, pane focus ring, selection
|
||||||
|
bar, accent `--accent` (crab orange-red) with color-mix
|
||||||
|
derivations, light/dark via light-dark() + persisted toggle,
|
||||||
|
phone breakpoint. Gate: styling.
|
||||||
|
- [x] **README + docs**: cbd-web/README.md (build, dev loop, config),
|
||||||
|
root README (web UI section, feature flag, build-web), update
|
||||||
|
architecture doc if the implementation deviates. Gate: build.
|
||||||
|
- [x] **Verification**: full workspace tests + clippy (native and
|
||||||
|
wasm) + fmt + markdownlint; live smoke test: server with bundle
|
||||||
|
→ browser fetch of `/`, gRPC-web call, TUI gRPC call, auth
|
||||||
|
denial over gRPC-web. Tick `quality/web-client.md`; write
|
||||||
|
`plan/summary.md` section; commit.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Plan: youtube-provider
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `ytdy/src/lib.rs`
|
||||||
|
and/or gates in `quality/youtube-provider.md`). The crate skeleton,
|
||||||
|
stubs, workspace/devenv wiring, and all tests exist; the tests fail on
|
||||||
|
`todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — Engine.** `run` (argv-only, `--no-warnings`, cookie flag,
|
||||||
|
timeout with `kill_on_drop`, stdout cap, typed errors), `probe`,
|
||||||
|
`flat_listing`, `video_entry`, `stream_urls`. Verifies:
|
||||||
|
`engine_failures_are_typed_never_panics`, parts of every other test;
|
||||||
|
gates "Engine".
|
||||||
|
- [x] **T2 — Path parsing.** `parse_path` for all `YtPath` shapes;
|
||||||
|
reject everything else. Verifies:
|
||||||
|
`foreign_and_malformed_paths_are_rejected`,
|
||||||
|
`tracks_resolve_streams_and_metadata` (is_track_path).
|
||||||
|
- [x] **T3 — Init.** Settings parse (defaults on broken TOML like the
|
||||||
|
other providers), engine construction, `--version` probe, cookies
|
||||||
|
readability check (degrade to logged out with a warning). Verifies:
|
||||||
|
`init_probes_the_binary`, `root_lists_playlists_only_when_logged_in`.
|
||||||
|
- [x] **T4 — Search.** Term registry (snapshot/register),
|
||||||
|
`search_term_node` (`ytsearchN:`), `create/rename/delete_lib_node`,
|
||||||
|
`get_lib_node` arms for Root/Search/SearchTerm, central
|
||||||
|
downloadable rule. Verifies:
|
||||||
|
`search_terms_are_created_listed_and_searched`,
|
||||||
|
`search_terms_rename_and_delete`.
|
||||||
|
- [x] **T5 — Playlists + tracks.** `playlists_node` (feed URL, login
|
||||||
|
gate), `playlist_node`, `get_urls_for_track` (`-g`),
|
||||||
|
`get_metadata_for_track` (single-video `-J`). Verifies:
|
||||||
|
`playlists_list_and_resolve_when_logged_in`,
|
||||||
|
`tracks_resolve_streams_and_metadata`.
|
||||||
|
- [x] **T6 — Orchestrator wiring.** `youtube_client: Option<Arc<_>>`,
|
||||||
|
non-fatal init from `ytdy.toml` (written back), routing arms in every
|
||||||
|
method, root child; `extension_for` gains `audio/webm`. Verifies:
|
||||||
|
existing orchestrator patterns by inspection; gates "Orchestrator
|
||||||
|
wiring"; `extension_prefers_content_type_then_url_then_bin` extended.
|
||||||
|
- [x] **T7 — Full verification.** Workspace suite green; clippy/fmt/
|
||||||
|
taplo/markdownlint clean; every gate ticked; no `todo!()`.
|
||||||
|
- [x] **T8 — Live smoke test.** With the devenv `yt-dlp`: search a term
|
||||||
|
through the provider, fetch a stream URL for one result, and download
|
||||||
|
a small capture through the capture store — remove the probe
|
||||||
|
afterwards. Validate the playlists feed invocation if cookies are
|
||||||
|
available; otherwise record it as unvalidated in the summary.
|
||||||
|
- [x] **T9 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/youtube-provider.md`.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Plan: youtube-rustypipe
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (ytdy tests and/or gates in
|
||||||
|
`quality/youtube-rustypipe.md`).
|
||||||
|
|
||||||
|
- [x] **T1 — Extractor trait + models.** `Extractor` (search_videos,
|
||||||
|
video, audio_stream_url, saved_playlists, playlist_videos) with
|
||||||
|
`VideoEntry`/`PlaylistEntry` models and a typed `ExtractError`;
|
||||||
|
`Client` holds a `Box<dyn Extractor>`. Verifies: gates "Extractor
|
||||||
|
seam".
|
||||||
|
- [x] **T2 — RustyPipeExtractor.** rustypipe client (rustls, storage
|
||||||
|
dir, timeout, retries), mp4a-preferring stream pick, cookie-txt
|
||||||
|
login with cache-first order, saved playlists + bounded playlist
|
||||||
|
pagination. Verifies: gates "Stream selection", "Login"; live probe.
|
||||||
|
- [x] **T3 — Client rework.** Settings without `binary`; init builds
|
||||||
|
the extractor non-fatally; logged-in gating from the login outcome;
|
||||||
|
all trait methods routed through the seam. Verifies: existing
|
||||||
|
behavioral tests, rewritten against the fake.
|
||||||
|
- [x] **T4 — Tests.** Port every ytdy test from fake-script to
|
||||||
|
FakeExtractor; add stream-preference and login-degradation tests.
|
||||||
|
Verifies: ytdy suite green without network.
|
||||||
|
- [x] **T5 — Environment.** Remove `yt-dlp` from `devenv.nix`; trim
|
||||||
|
ytdy `Cargo.toml` (drop process/io-util, add rustypipe); root
|
||||||
|
workspace dep. Verifies: gates "Environment".
|
||||||
|
- [x] **T6 — Full verification + live probe.** Workspace suite,
|
||||||
|
clippy/fmt/markdownlint; temporary ignored probe: search, stream
|
||||||
|
URL fetch, decode, download capture — removed after passing.
|
||||||
|
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile the architecture docs.
|
||||||
|
- [x] **T8 — Same-day fix: windowed fetching + stream sidecar.**
|
||||||
|
YouTube caps tokenless URLs at their leading ~1 MiB (measured; offset
|
||||||
|
starts refused; upstream web-client deciphering broken; botguard
|
||||||
|
ineffective through iOS). `WindowedHttpStream` in audio-player,
|
||||||
|
windowed capture downloads, `yt-dlp` back as a stream-URL-only
|
||||||
|
sidecar with degrade-and-fallback, `botguard_bin` passthrough,
|
||||||
|
capture deadline 30 min. Verifies: audio-player windowed tests,
|
||||||
|
capture window-chaining test, ytdy sidecar tests, gates "Stream
|
||||||
|
fetching"; live probe on the failing track.
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Quality gates: bookmarks
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automatic tests
|
||||||
|
(`fsdy/src/lib.rs`, `crabidy-server/src/bookmark_store.rs`, plus the TUI
|
||||||
|
tests added during implementation). Each gate is pass/fail by reading the
|
||||||
|
code.
|
||||||
|
|
||||||
|
## Capture
|
||||||
|
|
||||||
|
- [x] The walk is iterative (worklist), never recursive — a deep provider
|
||||||
|
tree cannot overflow the stack.
|
||||||
|
- [x] The caps bound *total* directories and tracks and abort with a typed
|
||||||
|
error; the temp folder is removed on every failure path (cap, walk
|
||||||
|
error, write error).
|
||||||
|
- [x] Captures write through the same primitives as queues
|
||||||
|
(`TrackFile::from_track`, `track_file_name`, and `dir_name` sharing one
|
||||||
|
sanitizer) — no second serialization or naming scheme.
|
||||||
|
- [x] Whole-bookmark writes are tmp-and-swap; overwriting an existing
|
||||||
|
bookmark never leaves a mix of old and new entries.
|
||||||
|
- [x] A source path that is a track captures as a folder with one file; an
|
||||||
|
unreadable source is `BadSource`, not a panic or an empty bookmark.
|
||||||
|
- [x] The capture runs on a spawned task; the orchestrator loop keeps
|
||||||
|
serving commands during a large walk.
|
||||||
|
- [x] No file contents in logs (paths, names, and counts only).
|
||||||
|
|
||||||
|
## Mutable top level (fsdy)
|
||||||
|
|
||||||
|
- [x] The `is_editable`/`is_deletable` flags appear **only** on direct
|
||||||
|
child folders of an editable instance's root, never on reserved names,
|
||||||
|
nested nodes, tracks, or immutable instances.
|
||||||
|
- [x] `rename_lib_node`/`delete_lib_node` enforce the same gate they
|
||||||
|
advertise: option on + direct child + not reserved; everything else is
|
||||||
|
`NotSupported` (matching the flags a client saw).
|
||||||
|
- [x] Rename validates the new title with the shared
|
||||||
|
`validate_folder_name` (reserved names rejected as targets) and never
|
||||||
|
merges onto an existing sibling (`InvalidInput`).
|
||||||
|
- [x] Delete is idempotent (already gone → success) and returns the
|
||||||
|
refreshed root listing; rename returns the renamed node at its new
|
||||||
|
path.
|
||||||
|
- [x] The `/queues` instance reserves `current`; `/bookmarks` reserves
|
||||||
|
nothing; `/fs` stays fully immutable.
|
||||||
|
- [x] Path traversal validation still happens only in `disk_path`; rename
|
||||||
|
and delete go through it for every path they touch.
|
||||||
|
|
||||||
|
## RPC and orchestrator
|
||||||
|
|
||||||
|
- [x] `capture_library_node` maps errors: invalid name/source →
|
||||||
|
`invalid_argument`, over-cap or disabled → `failed_precondition`,
|
||||||
|
walk/write failures → `internal`.
|
||||||
|
- [x] The orchestrator routes `/bookmarks` in every `ProviderClient`
|
||||||
|
method (same completeness as `/queues`), and `get_lib_root` lists the
|
||||||
|
`bookmarks` child only when the instance exists.
|
||||||
|
- [x] Bookmarks init is non-fatal: no config dir or an unopenable store
|
||||||
|
disables capture and the `/bookmarks` mount, never the server.
|
||||||
|
|
||||||
|
## TUI
|
||||||
|
|
||||||
|
- [x] `w` is bound in `Scope::Library` (queue's `w` untouched), has a help
|
||||||
|
description, and passes the bindings-table invariant tests unchanged.
|
||||||
|
- [x] The capture overlay opens only for a queueable bare selection,
|
||||||
|
prefilled with the selection's title; marks are ignored.
|
||||||
|
- [x] `MessageFromUi::CaptureNode` reaches the new rpc; a failed capture
|
||||||
|
is logged and never tears down the poll loop.
|
||||||
|
- [x] Renaming/deleting bookmark and saved-queue folders works through the
|
||||||
|
existing `e`/`d` flows with no TUI code changes.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||||
|
whole workspace; all tests green.
|
||||||
|
- [x] `architecture/queue-persistence.md` D8 reconciled (rename/delete of
|
||||||
|
saved queues is no longer out of scope).
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Quality gates: captures
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automatic tests
|
||||||
|
(`crabidy-server/src/capture.rs`, `crabidy-server/src/capture_store.rs`,
|
||||||
|
`fsdy/src/lib.rs`, plus the TUI tests added during implementation). Each
|
||||||
|
gate is pass/fail by reading the code.
|
||||||
|
|
||||||
|
## Shared walk (refactor)
|
||||||
|
|
||||||
|
- [x] Bookmarks and download captures run through **one** walk
|
||||||
|
(`capture::capture_into`/`write_tree`); `bookmark_store` keeps no copy
|
||||||
|
of the worklist, caps, tmp-and-swap, or cleanup logic.
|
||||||
|
- [x] The refactor is behavior-preserving for bookmarks: every existing
|
||||||
|
`bookmark_store` test passes unchanged (module path of `CaptureError`
|
||||||
|
aside).
|
||||||
|
- [x] The walk stays iterative (worklist), all-or-nothing, and removes the
|
||||||
|
temp folder on every failure path — including failed downloads and the
|
||||||
|
byte budget.
|
||||||
|
|
||||||
|
## Download sink
|
||||||
|
|
||||||
|
- [x] Every external call is bounded: connect timeout on the shared HTTP
|
||||||
|
client and one per-track deadline covering URL fetch, request, and the
|
||||||
|
whole body stream. No retries.
|
||||||
|
- [x] Bodies are **streamed** to disk (never buffered whole) and counted
|
||||||
|
against the capture's byte budget while streaming; exceeding it is
|
||||||
|
`TooLarge`, not partial data left behind.
|
||||||
|
- [x] Non-2xx responses and transport errors are typed
|
||||||
|
(`CaptureError::Download`) — no panic on any network condition.
|
||||||
|
- [x] Download log lines carry paths, names, and counts — never stream
|
||||||
|
URLs (they embed tokens) and never file contents.
|
||||||
|
- [x] The toml is written only after its audio file succeeded, with a
|
||||||
|
relative `file` playable naming the sibling
|
||||||
|
(`TrackFile::from_track_with_file`); the audio name shares the toml's
|
||||||
|
order prefix and sanitizer (`capture::audio_file_name`).
|
||||||
|
- [x] Downloads run sequentially inside the one spawned capture task; the
|
||||||
|
orchestrator loop keeps serving commands during a capture.
|
||||||
|
|
||||||
|
## Opt-in (is_downloadable)
|
||||||
|
|
||||||
|
- [x] Tidal sets the flag centrally: nodes are downloadable when queueable
|
||||||
|
or when they list tracks; children mirror `is_queable`. No per-arm
|
||||||
|
copies to drift.
|
||||||
|
- [x] Every other provider (fs, queues, bookmarks, captures, orchestrator
|
||||||
|
root) leaves the flag `false` — a capture can never be built from
|
||||||
|
another capture's or bookmark's links masquerading as sources.
|
||||||
|
- [x] The server enforces the blessing at the capture root (directory
|
||||||
|
source: its own node; track source: its parent node) with
|
||||||
|
`CaptureError::Unsupported`; an unreadable root is `BadSource`.
|
||||||
|
|
||||||
|
## RPC and orchestrator
|
||||||
|
|
||||||
|
- [x] `CaptureLibraryNodeRequest.download` is additive: old clients (no
|
||||||
|
flag) keep getting bookmarks, byte-for-byte.
|
||||||
|
- [x] Error mapping: `InvalidName`/`BadSource` → `invalid_argument`;
|
||||||
|
`TooLarge`/`Disabled`/`Unsupported` → `failed_precondition`;
|
||||||
|
download/walk/write failures → `internal`.
|
||||||
|
- [x] The orchestrator routes `/captures` in every `ProviderClient` method
|
||||||
|
(same completeness as `/bookmarks`), and `get_lib_root` lists the
|
||||||
|
`captures` child only when the instance exists.
|
||||||
|
- [x] Captures init is non-fatal: no config dir or an unopenable store
|
||||||
|
disables download captures and the `/captures` mount, never the server
|
||||||
|
(and never bookmarks).
|
||||||
|
- [x] `/captures` mounts with an editable top level (no reserved names):
|
||||||
|
captures are renamable (`e`) and deletable (`d`) like bookmarks.
|
||||||
|
|
||||||
|
## TUI
|
||||||
|
|
||||||
|
- [x] `W` (shift) is bound in `Scope::Library` with a help description and
|
||||||
|
passes the bindings-table invariant tests unchanged; `w` behavior is
|
||||||
|
untouched.
|
||||||
|
- [x] The capture overlay opens for `W` only when the bare selection is
|
||||||
|
queueable **and** downloadable; tracks inherit their node's flag;
|
||||||
|
marks are ignored. Label: `capture` (vs `bookmark`).
|
||||||
|
- [x] `MessageFromUi::CaptureNode` carries `download`; a failed capture is
|
||||||
|
logged and never tears down the poll loop.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||||
|
whole workspace; all tests green.
|
||||||
|
- [x] `architecture/captures.md` reconciled where the implementation
|
||||||
|
diverged (e.g. the exact tidal flag rule).
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Quality gates: cbd-bundle
|
||||||
|
|
||||||
|
Criteria beyond the automatic tests (`cbd/src/main.rs` readiness tests
|
||||||
|
plus the moved suites). Each gate is pass/fail by reading the code.
|
||||||
|
|
||||||
|
## Refactor (behavior-preserving)
|
||||||
|
|
||||||
|
- [x] The server's modules (`playback`, `provider`, `rpc`) and command/
|
||||||
|
message types moved to the library **unchanged**; `serve(addr)` is the
|
||||||
|
old `main` body verbatim (minus tracing); both thin mains only set up
|
||||||
|
tracing and delegate.
|
||||||
|
- [x] Every pre-existing test moved along and passes unchanged; no
|
||||||
|
test was rewritten to accommodate the refactor.
|
||||||
|
- [x] `cbd-tui`'s library exposes `run(config)`; tracing setup stays in
|
||||||
|
the binaries (where logs go is a hosting decision).
|
||||||
|
|
||||||
|
## Bundled behavior
|
||||||
|
|
||||||
|
- [x] `cbd` uses the same configs as the pair (`cbd-tui.toml` and the
|
||||||
|
server-side configs) — nothing bundled-specific to configure.
|
||||||
|
- [x] Both halves log to one file; the terminal is never written to
|
||||||
|
outside the TUI.
|
||||||
|
- [x] An already-running standalone server is adopted (occupied port +
|
||||||
|
reachable socket → connect), never treated as an error.
|
||||||
|
- [x] A server that dies before readiness surfaces its actual error;
|
||||||
|
a slow one gets a bounded, generous wait (first-run login flows).
|
||||||
|
- [x] The readiness poll strips the config URL's scheme and never
|
||||||
|
parses beyond host:port.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean; all
|
||||||
|
tests green.
|
||||||
|
- [x] `architecture/cbd-bundle.md` reconciled where the implementation
|
||||||
|
diverged.
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Quality gates: fs-provider
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automated tests in
|
||||||
|
`fsdy/src/lib.rs`. Check each by reading the code and reasoning; tick only
|
||||||
|
when verified.
|
||||||
|
|
||||||
|
## Robustness (hard rules)
|
||||||
|
|
||||||
|
- [x] No code path panics on the contents of the tree: unreadable files,
|
||||||
|
invalid TOML, invalid UTF-8 file names, symlink cycles, and permission
|
||||||
|
errors all end in a typed error or a warn-and-skip — no `unwrap`/
|
||||||
|
`expect`/`panic!`/indexing on tree- or client-derived data outside
|
||||||
|
tests.
|
||||||
|
- [x] Client-supplied paths cannot address anything outside the configured
|
||||||
|
root: every decoded segment is validated (`.`, `..`, empty, separator)
|
||||||
|
before joining, in **one** place that all lookups go through.
|
||||||
|
- [x] All provider I/O is `tokio::fs` — no `std::fs` outside `#[cfg(test)]`
|
||||||
|
(nothing blocks the runtime).
|
||||||
|
- [x] Directory listing skips symlinks without following them (no cycle can
|
||||||
|
hang the walk; the root cannot be escaped via links).
|
||||||
|
|
||||||
|
## Contract fidelity
|
||||||
|
|
||||||
|
- [x] A `link` playable rewrites `Track.path` to the target everywhere a
|
||||||
|
`Track` is built (listing and metadata) — there is a single
|
||||||
|
file-to-`Track` conversion both call.
|
||||||
|
- [x] `link` targets pointing into `/fs` are rejected at parse time, so
|
||||||
|
link chains are structurally impossible.
|
||||||
|
- [x] `get_urls_for_track` returns plain file paths and http(s) URLs only —
|
||||||
|
never `file://` URLs (the player rejects unknown schemes).
|
||||||
|
- [x] A broken track file is skipped **with a warning that names the file**
|
||||||
|
and does not remove its siblings from the listing.
|
||||||
|
- [x] `create/rename/delete_lib_node` return `NotSupported`; no fs mutation
|
||||||
|
API sneaks in.
|
||||||
|
- [x] `resolve_tracks_into` keeps the trait default (no override), and the
|
||||||
|
listing order equals the resolve order (both come from the same sorted
|
||||||
|
listing).
|
||||||
|
|
||||||
|
## Orchestrator wiring
|
||||||
|
|
||||||
|
- [x] Every `ProviderClient` method on `ProviderOrchestrator` routes `/fs`
|
||||||
|
and `/fs/...` to the fs client, mirroring the `/tidal` arms (including
|
||||||
|
`is_track_path` and `resolve_tracks_into`).
|
||||||
|
- [x] fs init failure is non-fatal: the server starts, logs a warning, and
|
||||||
|
`/fs` is absent from the root listing; no `/fs`-routing arm can panic
|
||||||
|
when the client is absent.
|
||||||
|
- [x] The `fsdy.toml` config is written back with effective defaults on
|
||||||
|
first run, like `tidaly.toml`.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] Every public item in `fsdy` has a doc comment stating intent and
|
||||||
|
error behavior; the on-disk format is documented where the schema type
|
||||||
|
is defined.
|
||||||
|
- [x] Warnings/errors never include file *contents* (a track file may hold
|
||||||
|
a private URL with a token) — log paths and error kinds, not bodies.
|
||||||
|
- [x] `cargo clippy` is warning-free; `cargo fmt`, `taplo`, `markdownlint`
|
||||||
|
clean; no `todo!()`/`unimplemented!()` remains.
|
||||||
|
- [x] The whole workspace test suite passes, not just `fsdy`.
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Quality gates — help modal (cbd-tui)
|
||||||
|
|
||||||
|
Checklist for the `implement` stage. Each gate is pass/fail and verified by
|
||||||
|
reading the code (the automatic tests live in
|
||||||
|
`cbd-tui/src/app/{bindings,help,mod}.rs` `#[cfg(test)]` modules and must pass
|
||||||
|
via `devenv shell -- cargo test -p cbd-tui`).
|
||||||
|
|
||||||
|
## Single source of truth
|
||||||
|
|
||||||
|
- [x] `bindings::BINDINGS` is the only encoding of key → action → description.
|
||||||
|
`help.rs` contains no hardcoded key names or binding descriptions other
|
||||||
|
than the usage paragraph; key labels come from `bindings::key_label`.
|
||||||
|
- [x] The old `match (app.focus, key.modifiers, key.code)` in `main.rs` is
|
||||||
|
fully replaced by `bindings::lookup` + `App::dispatch`. No key handling
|
||||||
|
remains in `main.rs` besides translating `KeyEvent` → `Action` and
|
||||||
|
honoring `DispatchResult::Quit`.
|
||||||
|
- [x] Behavior parity: every binding that existed before the refactor
|
||||||
|
(main.rs match arms at the pre-change commit) maps to a `BINDINGS` entry
|
||||||
|
triggering the same underlying call. Cross-check arm by arm.
|
||||||
|
|
||||||
|
## Modality
|
||||||
|
|
||||||
|
- [x] While `show_help` is true, only `Scope::Help` entries dispatch; `q`
|
||||||
|
closes the modal and does not quit the app.
|
||||||
|
- [x] `Scope::Help` entries never dispatch while the modal is closed.
|
||||||
|
- [x] The overlay is drawn last in `App::render` and uses ratatui's `Clear`
|
||||||
|
before drawing the popup, so pane content never bleeds through.
|
||||||
|
|
||||||
|
## No panics on user input (AGENTS.md hard rule)
|
||||||
|
|
||||||
|
- [x] All `todo!()` stubs from api-design are gone from `bindings.rs`,
|
||||||
|
`help.rs`, and `App::dispatch`.
|
||||||
|
- [x] `lookup`, `key_label`, `dispatch`, and `help::render` cannot panic for
|
||||||
|
any `KeyEvent` or any frame size (including 1×1); no `unwrap`/`expect`
|
||||||
|
/indexing on user-driven paths. Unknown keys are ignored (`None`), not
|
||||||
|
errors.
|
||||||
|
- [x] Channel send failures in `dispatch` are ignored (`let _ =`), matching
|
||||||
|
the existing module convention — no `unwrap` on `tx.send`.
|
||||||
|
|
||||||
|
## Robust key matching
|
||||||
|
|
||||||
|
- [x] `lookup` ignores `SHIFT` when comparing `KeyCode::Char` chords and
|
||||||
|
requires exact matches for all other modifiers (`Ctrl+n` ≠ `n`).
|
||||||
|
- [x] Key events with `kind != KeyEventKind::Press` are still filtered out
|
||||||
|
before lookup (existing behavior preserved).
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
- [x] Public items in `bindings.rs` and `help.rs` keep doc comments that match
|
||||||
|
the implemented behavior (update them if implementation details shift).
|
||||||
|
- [x] No new external dependencies in `cbd-tui/Cargo.toml`.
|
||||||
|
- [x] `devenv shell -- cargo fmt --check`, `cargo clippy` (no new warnings,
|
||||||
|
no dead-code warnings remaining for `bindings`/`help`), and
|
||||||
|
`cargo test -p cbd-tui` all pass.
|
||||||
|
- [x] The `TODO(api-design)` marker on the `LibraryQueueNext` description is
|
||||||
|
resolved: the wording matches what `MessageFromUi::QueueTracks` actually
|
||||||
|
does server-side.
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Quality gates: incremental-captures
|
||||||
|
|
||||||
|
Criteria beyond the automatic tests. Each gate is pass/fail by reading the
|
||||||
|
code. Tests live next to the modules they cover (fsdy, capture,
|
||||||
|
capture_store, playback, cbd-tui).
|
||||||
|
|
||||||
|
## Skipped playable (D1)
|
||||||
|
|
||||||
|
- [x] `[playable] skipped = true` parses; `skipped = false`, `skipped`
|
||||||
|
combined with another field, and an empty table are typed
|
||||||
|
`PlayableCardinality`-style errors — never panics.
|
||||||
|
- [x] `from_track` on an `is_skipped` wire track writes a skipped playable
|
||||||
|
(queue persistence and bookmarks keep skipped-ness); `to_track` sets
|
||||||
|
`is_skipped` and keeps the lib path.
|
||||||
|
- [x] `get_urls_for_track` on a skipped file is a typed error, not empty-vec
|
||||||
|
success.
|
||||||
|
|
||||||
|
## Incremental capture (D2)
|
||||||
|
|
||||||
|
- [x] Download captures write into the final folder with no tmp/swap and
|
||||||
|
never delete existing entries; bookmark captures keep tmp-and-swap
|
||||||
|
byte-identically (their existing tests pass unchanged).
|
||||||
|
- [x] Enumeration enforces `max_dirs`/`max_tracks` before any download;
|
||||||
|
the byte budget counts only bytes downloaded this run.
|
||||||
|
- [x] Reuse rule: parseable toml + non-skipped playable (+ existing file for
|
||||||
|
`file` playables) is not re-downloaded; skipped/broken/missing-audio
|
||||||
|
entries are re-captured.
|
||||||
|
- [x] Uncapturable tracks write skipped tomls (no more silent omission);
|
||||||
|
real download failures abort the run but keep prior writes.
|
||||||
|
- [x] Stream URLs and cookies never appear in errors, logs, or progress
|
||||||
|
events (only names, paths, counts).
|
||||||
|
|
||||||
|
## Progress + RPC (D4)
|
||||||
|
|
||||||
|
- [x] `CaptureLibraryNode` replies after validation (name, store enabled,
|
||||||
|
download blessing) with the existing status mapping; the walk runs
|
||||||
|
spawned and reports through `CaptureProgress` updates ending in exactly
|
||||||
|
one `finished` event (success or error).
|
||||||
|
- [x] The progress channel is bounded; a vanished receiver does not abort
|
||||||
|
the capture.
|
||||||
|
|
||||||
|
## Playback (D3)
|
||||||
|
|
||||||
|
- [x] `play` skips `is_skipped` tracks without a provider call and bounds
|
||||||
|
skipping by the queue length at entry: an all-skipped repeat queue stops
|
||||||
|
with a warning instead of spinning.
|
||||||
|
|
||||||
|
## TUI (D5, D7)
|
||||||
|
|
||||||
|
- [x] Skipped tracks render red in queue and library; the current-track
|
||||||
|
marker keeps precedence.
|
||||||
|
- [x] Progress lines show name, done/total, and skipped count; finished
|
||||||
|
lines expire on the render tick; errors render red.
|
||||||
|
- [x] The `W` binding description and the download-capture input label warn
|
||||||
|
that captures can take long and resume by name.
|
||||||
|
- [x] The selected row of a focused pane renders colored items with the dark
|
||||||
|
foreground (readable on the light bar); unfocused panes keep the colored
|
||||||
|
foregrounds.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New/changed public items documented, stating error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean; all tests
|
||||||
|
green.
|
||||||
|
- [x] `architecture/incremental-captures.md` reconciled where the
|
||||||
|
implementation diverged.
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Quality gates — editable and deletable nodes
|
||||||
|
|
||||||
|
Checklist for the `implement` stage. Automatic tests live in
|
||||||
|
`crabidy-core/src/lib.rs`, `tidaldy/src/lib.rs`, and
|
||||||
|
`cbd-tui/src/app/{bindings,mod}.rs` test modules; run with
|
||||||
|
`devenv shell -- cargo test --workspace` (network-dependent tidaldy tests
|
||||||
|
stay `#[ignore]`). 10 tests fail at gate-writing time — they define the
|
||||||
|
target.
|
||||||
|
|
||||||
|
## Contract & wire
|
||||||
|
|
||||||
|
- [x] Proto changes are additive only: existing field numbers untouched;
|
||||||
|
`LibraryNodeChild.is_editable = 5`, `is_deletable = 6`;
|
||||||
|
`RenameLibraryNode` and `DeleteLibraryNode` rpcs present. Old clients
|
||||||
|
keep working against the new server.
|
||||||
|
- [x] `rpc.rs` (server) maps errors per the documented contract for **both**
|
||||||
|
new rpcs: `NotSupported` → `failed_precondition`, `InvalidInput` →
|
||||||
|
`invalid_argument`, everything else → `internal`. No `color-eyre`
|
||||||
|
report or debug formatting of internals leaks into `Status` messages.
|
||||||
|
- [x] `ProviderOrchestrator::{rename,delete}_lib_node` route `/tidal`-prefixed
|
||||||
|
paths to the Tidal client and answer anything else (including the
|
||||||
|
synthetic root `/`) with `NotSupported` — same prefix discipline as
|
||||||
|
`create_lib_node`.
|
||||||
|
- [x] Both new commands follow the existing bounded(1)-reply rendezvous
|
||||||
|
pattern; no new unbounded channels anywhere in the feature.
|
||||||
|
|
||||||
|
## Provider semantics (tidaldy)
|
||||||
|
|
||||||
|
- [x] `rename_lib_node` trims the new title; empty/whitespace →
|
||||||
|
`InvalidInput`; any path that is not a `SearchTerm` (including
|
||||||
|
`/tidal/search` itself) → `NotSupported`; validation happens before any
|
||||||
|
network call.
|
||||||
|
- [x] Rename replaces the term **in place** (keeps its list position);
|
||||||
|
renaming onto an existing term merges (the old slot is removed, no
|
||||||
|
duplicates ever); renaming an unknown term registers the new one
|
||||||
|
(stale-client forgiveness). Returns `get_lib_node(new_path)` — the
|
||||||
|
node at its new percent-encoded path.
|
||||||
|
- [x] `delete_lib_node` accepts only `SearchTerm` paths (`NotSupported`
|
||||||
|
otherwise), removes the term idempotently (unknown term → success),
|
||||||
|
and returns the refreshed `/tidal/search` parent node.
|
||||||
|
- [x] `rename_search_term` / `remove_search_term` follow the established
|
||||||
|
lock discipline: poison-tolerant, the `search_terms` lock is never
|
||||||
|
held across an `await`.
|
||||||
|
- [x] Term children returned by the `Search` arm set
|
||||||
|
`is_editable: true, is_deletable: true`; nothing else in the provider
|
||||||
|
sets either flag.
|
||||||
|
- [x] Queued search tracks keep playing after a rename/delete of their term:
|
||||||
|
`get_urls_for_track` / `get_metadata_for_track` resolve from the track
|
||||||
|
id embedded in the path, independent of term registration.
|
||||||
|
|
||||||
|
## No panics on user input (hard rule)
|
||||||
|
|
||||||
|
- [x] All `todo!()` stubs from api-design are gone (grep the workspace).
|
||||||
|
- [x] Rename/delete failures (network, auth, malformed paths from stale
|
||||||
|
clients) surface as `ProviderError`/`Status`, never a panic; the TUI's
|
||||||
|
rename and delete paths handle an error reply without crashing the
|
||||||
|
orchestrate task (log + stay put).
|
||||||
|
|
||||||
|
## TUI behavior
|
||||||
|
|
||||||
|
- [x] `e` opens the overlay only when the **selected** item `is_editable`,
|
||||||
|
prefilled with the current title; `d` sends `DeleteNode` only when the
|
||||||
|
selected item `is_deletable`; both are silent no-ops otherwise
|
||||||
|
(including when nothing is selected or the list is empty).
|
||||||
|
- [x] The overlay carries its purpose: submit sends `CreateNode` for
|
||||||
|
`InputPurpose::Create` and `RenameNode` for `InputPurpose::Rename`;
|
||||||
|
the rendered label distinguishes them (`new node:` vs `rename:`).
|
||||||
|
While the overlay is open the bindings table stays unreachable
|
||||||
|
(existing input-mode bypass, unchanged).
|
||||||
|
- [x] Delete is deliberately unconfirmed (architecture/node-editing.md D4);
|
||||||
|
the open question about confirmation for higher-value nodes is
|
||||||
|
preserved in the architecture doc, not silently dropped.
|
||||||
|
- [x] Modifiable children are visibly marked in the library list (`[e]`,
|
||||||
|
`[d]` or `[ed]` suffix, `COLOR_SECONDARY`), coexisting with the `[%]`
|
||||||
|
marker for creatable ones.
|
||||||
|
- [x] `RpcClient::rename_library_node` evicts the old path **and** the
|
||||||
|
parent entry, then caches the node under its new path;
|
||||||
|
`delete_library_node` evicts the deleted path and the parent, then
|
||||||
|
caches the returned parent. No stale `/tidal/search` listing can
|
||||||
|
resurrect an old term.
|
||||||
|
- [x] On successful rename the library navigates into the renamed node; on
|
||||||
|
successful delete it shows the refreshed parent; on failure the
|
||||||
|
library stays where it was.
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
- [x] Public items added in all five crates have doc comments matching final
|
||||||
|
behavior; the `e`/`d` bindings appear in the help modal automatically.
|
||||||
|
- [x] No new dependencies.
|
||||||
|
- [x] `devenv shell -- cargo fmt --check`, `cargo clippy --workspace`
|
||||||
|
(no new warnings), `cargo test --workspace` all pass.
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Quality gates — progressive queueing
|
||||||
|
|
||||||
|
Criteria for `architecture/progressive-queueing.md`. Automatic tests live in
|
||||||
|
`crabidy-core/src/lib.rs` (default `resolve_tracks_into` walk, fake
|
||||||
|
provider), `crabidy-server/src/lib.rs` (`PendingResolve` chunk application),
|
||||||
|
`tidaldy/src/lib.rs` (offline path validation), and
|
||||||
|
`cbd-tui/src/app/queue.rs` (indicator rendering). They fail while the stubs
|
||||||
|
are unimplemented — that is the target state for `implement`.
|
||||||
|
|
||||||
|
Run: `devenv shell -- cargo test --workspace` (session-local
|
||||||
|
`CARGO_TARGET_DIR` when `target/` is owner-built).
|
||||||
|
|
||||||
|
## Channel and concurrency gates (verify by reading)
|
||||||
|
|
||||||
|
- [x] **All new channels are bounded.** The chunk channel is small (≤ 8
|
||||||
|
chunks); forwarder → playback reuses the existing `bounded(64)`. No
|
||||||
|
`unbounded()` anywhere in the feature.
|
||||||
|
- [x] **No lock is held across an `await`.** The `pending` map and `queue`
|
||||||
|
mutexes are locked, used, and released inside synchronous blocks only
|
||||||
|
— same discipline as the existing playback handlers.
|
||||||
|
- [x] **The playback loop stays the single writer of queue state.** Neither
|
||||||
|
the forwarder task nor the provider resolve task touches
|
||||||
|
`QueueManager` or broadcasts; they only send commands.
|
||||||
|
- [x] **The provider loop never blocks on a resolve.** The `ResolveTracks`
|
||||||
|
arm spawns; `GetTrackUrls` for the first chunk's track must be
|
||||||
|
servable while a resolve is still streaming (deadlock check: playback
|
||||||
|
awaiting `GetTrackUrls` + provider awaiting a chunk send must be
|
||||||
|
impossible).
|
||||||
|
- [x] **Channel semantics are documented where they are the contract**:
|
||||||
|
sender-drop = finished, receiver-drop = cancel, on both
|
||||||
|
`ProviderClient::resolve_tracks_into` and
|
||||||
|
`ProviderCommand::ResolveTracks`.
|
||||||
|
|
||||||
|
## Cancellation gates
|
||||||
|
|
||||||
|
- [x] **`Replace` and `Clear` cancel every pending op** before mutating the
|
||||||
|
queue; `Queue`/`Append`/`Insert` cancel nothing.
|
||||||
|
- [x] **Cancellation propagates to the network fetch**: the forwarder drops
|
||||||
|
the chunk receiver, and both resolve implementations (default walk and
|
||||||
|
tidaldy override) treat a failed chunk send as "stop fetching, return
|
||||||
|
Ok" — verified by test for the default, by reading for tidaldy's page
|
||||||
|
loop.
|
||||||
|
- [x] **Late chunks for finished/cancelled ops are dropped silently** (no
|
||||||
|
error, no queue mutation).
|
||||||
|
|
||||||
|
## Behavior gates
|
||||||
|
|
||||||
|
- [x] **Immediate feedback**: accepting a resolve op broadcasts a `Queue`
|
||||||
|
snapshot with `resolving = true` before the first chunk arrives.
|
||||||
|
- [x] **`resolving` is true iff at least one op is pending**, and every
|
||||||
|
queue broadcast from the playback loop goes through the one helper
|
||||||
|
that sets it (grep: no direct `StreamUpdate::Queue(` construction in
|
||||||
|
the op/chunk paths besides `broadcast_queue`).
|
||||||
|
- [x] **Playback starts with the first chunk** that makes a track current
|
||||||
|
(replace, or landing in an empty queue) and never restarts for later
|
||||||
|
chunks of the same op — covered by the `PendingResolve` tests, plus
|
||||||
|
reading the `apply_resolved_chunk` → `play_if_some` wiring.
|
||||||
|
- [x] **Order preserved end-to-end**: pages arrive in collection order,
|
||||||
|
nodes in listing order (pre-order walk — the old LIFO reversal must
|
||||||
|
not reappear), multi-path requests resolve sequentially in request
|
||||||
|
order, and `InsertAfter` keeps the whole op contiguous.
|
||||||
|
- [x] **An op that finishes with zero tracks logs the existing
|
||||||
|
"resolved to no playable tracks" warning** with enough context (paths).
|
||||||
|
|
||||||
|
## Error and robustness gates
|
||||||
|
|
||||||
|
- [x] **No panics on provider failure, in any spawned task.** Unreadable
|
||||||
|
nodes are skipped with a warning; a dead provider channel ends the op
|
||||||
|
(with `ResolveFinished` still sent, so `resolving` clears) instead of
|
||||||
|
leaving a stuck indicator.
|
||||||
|
- [x] **Tracing spans survive the spawns**: forwarder and provider resolve
|
||||||
|
tasks attribute their events to the originating request's span
|
||||||
|
(`ProviderMessage::new` capture + `in_current_span`/explicit parent).
|
||||||
|
- [x] **tidaldy page loop honors the existing token/refresh path** (reuses
|
||||||
|
`make_request`) and adds no new retry logic.
|
||||||
|
|
||||||
|
## Wire and UI gates
|
||||||
|
|
||||||
|
- [x] **Proto change is additive only**: `Queue.resolving = 4`, no rpc shape
|
||||||
|
changes; field documented in the proto.
|
||||||
|
- [x] **The TUI pseudo-item is render-only**: never in the list model,
|
||||||
|
unreachable by selection/removal/navigation (`get_size` unchanged) —
|
||||||
|
covered by tests.
|
||||||
|
- [x] **Indicator style matches the pane** (`COLOR_SECONDARY`, inside the
|
||||||
|
queue block, after the last track).
|
||||||
|
- [x] **Old client / new server and new client / old server both degrade
|
||||||
|
cleanly** (flag ignored / never set — reasoning check against the
|
||||||
|
generated proto defaults).
|
||||||
|
|
||||||
|
## Documentation gates
|
||||||
|
|
||||||
|
- [x] **`ProviderClient::resolve_tracks_into` docs state the full channel
|
||||||
|
contract** (order, completion, cancellation, error policy).
|
||||||
|
- [x] **Deviations from the architecture are recorded** in
|
||||||
|
`plan/summary.md` under this feature.
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Quality gates: queue-persistence
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automatic tests
|
||||||
|
(`fsdy/src/lib.rs`, `crabidy-server/src/queue_store.rs`, plus the playback
|
||||||
|
and TUI tests added during implementation). Each gate is pass/fail by
|
||||||
|
reading the code.
|
||||||
|
|
||||||
|
## fsdy instances and serialization
|
||||||
|
|
||||||
|
- [x] Every occurrence of the hardcoded `"/fs"`/`"/fs/"` prefix inside
|
||||||
|
`fsdy::Client` methods now derives from the instance's `provider_root`;
|
||||||
|
`PROVIDER_ROOT` remains only as the default instance's constant
|
||||||
|
(`init`) and for external callers.
|
||||||
|
- [x] Path traversal validation still happens in exactly one place
|
||||||
|
(`disk_path`) and applies to every instance.
|
||||||
|
- [x] `TrackFile::from_track` is the single Track→file conversion site,
|
||||||
|
and it always emits a `link` playable (no special cases per provider).
|
||||||
|
- [x] Serialization never panics: `to_toml` returns
|
||||||
|
`TrackFileError::Serialize`, and every `Option` field is
|
||||||
|
skip-serialized (TOML cannot represent `None`).
|
||||||
|
- [x] The one-hop link argument holds in code: `get_urls_for_track` still
|
||||||
|
returns `MalformedPath` for a link playable, so removing the
|
||||||
|
`LinkIntoFs` rejection cannot introduce recursion anywhere.
|
||||||
|
|
||||||
|
## Queue store
|
||||||
|
|
||||||
|
- [x] `QueueStore` is the only writer of the queues directory; the
|
||||||
|
`/queues` provider instance only reads.
|
||||||
|
- [x] Writes are tmp-and-swap: entries are written to a hidden
|
||||||
|
(dot-prefixed) temp sibling, then the old folder is removed and the
|
||||||
|
temp renamed. No code path writes entries into the live folder
|
||||||
|
directly.
|
||||||
|
- [x] `save` validates the name first and never touches disk for an
|
||||||
|
invalid name or empty snapshot.
|
||||||
|
- [x] `load_current` never fails the server: missing folder → `None`,
|
||||||
|
broken entry → skip with a warning naming the file (never its
|
||||||
|
contents), broken sidecar → default state with a warning.
|
||||||
|
- [x] File names come from `fsdy::track_file_name` — no second naming
|
||||||
|
scheme.
|
||||||
|
- [x] No file contents in logs anywhere in the store (paths and names
|
||||||
|
only).
|
||||||
|
|
||||||
|
## Playback wiring
|
||||||
|
|
||||||
|
- [x] The playback loop never blocks on disk: auto-persist goes through
|
||||||
|
the `watch` channel (latest wins), `SaveQueue` writes on a spawned
|
||||||
|
task that reports back through the command's result channel.
|
||||||
|
- [x] Every queue-state change reaches the persist channel: queue
|
||||||
|
content changes (the `broadcast_queue` funnel), current-track changes
|
||||||
|
(`play`), and the shuffle/repeat toggles.
|
||||||
|
- [x] The persister task debounces and skips snapshots equal to the last
|
||||||
|
one written (pure `resolving`-flag broadcasts must not rewrite the
|
||||||
|
folder).
|
||||||
|
- [x] Persist failures are warnings; no persist error can stop playback
|
||||||
|
or crash the loop.
|
||||||
|
- [x] The startup restore runs before the playback loop serves commands,
|
||||||
|
restores tracks + position + repeat/shuffle, and never starts
|
||||||
|
playback (`PlayState::Stopped`).
|
||||||
|
- [x] Restore tolerates a corrupt position (out of range → clamped or
|
||||||
|
reset, never a panic).
|
||||||
|
- [x] A server without a usable queues directory (no config dir, mkdir
|
||||||
|
fails) runs without persistence after a warning — never dies.
|
||||||
|
|
||||||
|
## RPC and orchestrator
|
||||||
|
|
||||||
|
- [x] `save_queue` maps errors: invalid name → `invalid_argument`, empty
|
||||||
|
queue → `failed_precondition`, I/O → `internal`; no `color-eyre`/debug
|
||||||
|
reports leak to clients.
|
||||||
|
- [x] The orchestrator routes `/queues` in **every** `ProviderClient`
|
||||||
|
method (same completeness as `/fs`), and `get_lib_root` lists the
|
||||||
|
`queues` child only when the instance exists.
|
||||||
|
- [x] `/queues` mutations via the library stay `NotSupported`
|
||||||
|
(create/rename/delete unchanged).
|
||||||
|
|
||||||
|
## TUI
|
||||||
|
|
||||||
|
- [x] `w` is bound in `Scope::Queue` only, has a help description, and
|
||||||
|
passes the existing bindings-table invariant tests unchanged.
|
||||||
|
- [x] The save overlay reuses `InputState` (Esc cancels, Enter submits
|
||||||
|
trimmed, empty submit closes silently) and is a no-op while the queue
|
||||||
|
is empty.
|
||||||
|
- [x] `MessageFromUi::SaveQueue` reaches the `SaveQueue` RPC; a failed
|
||||||
|
save must not crash the TUI.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||||
|
whole workspace; all tests green.
|
||||||
|
- [x] `architecture/fs-provider.md` reconciled: the "no links into /fs"
|
||||||
|
rule replaced by the one-hop semantics, D2's "chains structurally
|
||||||
|
impossible" wording updated.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue