Compare commits

..

No commits in common. "ef69afdd5ff84c39b0a5580166b1e44a7e89724c" and "9706e3b5d2a11fbf53753c25673b83e03214ea29" have entirely different histories.

115 changed files with 844 additions and 25868 deletions

1461
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,10 @@
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]
@ -18,28 +14,18 @@ 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",
@ -54,10 +40,6 @@ 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"
@ -66,23 +48,13 @@ 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"
# default-features = false so crabidy-core can select codegen-only for tonic = "0.14"
# 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"] }
@ -90,9 +62,5 @@ 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
View File

@ -1,190 +1 @@
# 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.

View File

@ -1,184 +0,0 @@
# 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.

View File

@ -1,98 +0,0 @@
# 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.

View File

@ -1,228 +0,0 @@
# 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,269 +0,0 @@
# Progressive queueing of large collections
## Context and problem statement
Queueing a nested node (an artist with many albums, a large playlist) today
freezes the UI's mental model: nothing changes for many seconds, then the full
queue appears at once. Three compounding causes, all in the resolve path:
1. `Playback::resolve_tracks` collects **every** track before the queue is
touched, so the single `Queue` broadcast happens only at the very end and
playback cannot start earlier.
2. The resolve runs **inline in the playback loop**, so every other playback
command — pause, next, volume — is blocked for the duration.
3. `tidaldy` paginates collections to exhaustion (50 tracks per sequential
request) inside one `get_lib_node` call, so even a single large playlist
produces no intermediate result.
The feature: resolve progressively. Tracks are applied to the queue in chunks
as the provider produces them, each chunk is broadcast, playback starts with
the first chunk, and clients see a loading indicator (animated dots as a
pseudo last queue item) while resolution is still running.
Run autonomously per standing user instruction; every decision below records
the options considered and the rationale.
## Assumptions
- Chunks must arrive **in playback order** — the user explicitly wants "the
first elements first, and later chunks follow". Order is known up front
(collection order), so no reordering step is needed.
- The four resolve-based queue operations (`Replace`, `Queue`, `Append`,
`Insert`) all benefit equally and should share one mechanism.
- Wire changes must stay additive (old clients keep working; they simply see
the queue fill progressively without an indicator).
- Multi-client remains supported: the indicator must be server-derived state,
not client-local guessing.
## Decisions
### D1 — End-to-end progressive resolution, not a client-only spinner
Options considered:
1. **Client-only indicator**: the TUI shows dots between sending a queue op
and receiving the next `Queue` update. No wire or server change.
2. **Server-side chunked resolution** with a wire-visible "still resolving"
flag; the indicator falls out of the flag.
**Decision: (2).** Option 1 papers over the latency without fixing it —
playback would still start only after the full resolve, other clients would
see nothing, and the "did it even register?" dead time remains. Option 2
fixes the actual complaint (start playing early, fill visibly) and gives
every client the indicator for free. The server broadcasts an immediate
`Queue` update (unchanged tracks, `resolving = true`) when the op is
accepted, so feedback appears within one round trip.
### D2 — `Queue.resolving` field, not a new stream-update variant
Options considered:
1. New `GetUpdateStream` oneof variant `resolving(bool)`.
2. New field `bool resolving = 4` on the `Queue` message itself.
**Decision: (2).** The flag is queue state and must be atomic with the track
snapshot it describes; a separate variant can arrive out of order relative to
`Queue` updates (the broadcast channel is lossy for slow clients). Additive
field, wire-compatible both ways: old clients ignore it, old servers never
set it.
### D3 — Chunked resolution lives in `ProviderClient`, with a default
Options considered:
1. Keep the BFS in `ProviderOrchestrator` (chunk = one node's tracks); no
trait change. A 1000-track playlist is still one 20-request blob.
2. Add a chunked resolve method to the `ProviderClient` trait with a default
implementation (the generic walk, one chunk per node); `tidaldy`
overrides it to stream **page-sized chunks (50)** for the paginated
collections (playlists, albums).
**Decision: (2).**
```rust
/// Streams the playable tracks under `path` into `chunk_tx` in playback
/// order. Zero or more chunks, then the sender is dropped: a dropped
/// SENDER means resolution finished. A dropped RECEIVER cancels
/// resolution (the provider stops fetching and returns Ok).
async fn resolve_tracks_into(
&self,
path: &str,
chunk_tx: flume::Sender<Vec<Track>>,
) -> Result<(), ProviderError>;
```
The channel semantics are the contract and are documented on the trait —
this is a local bounded channel used as a stream, not a queue pretending to
be durable. Unreadable nodes are skipped with a warning (today's behavior);
only a completely unresolvable root returns an error. The default
implementation walks the tree **depth-first pre-order** over queueable
descendants, emitting one chunk per node. This replaces the old orchestrator
BFS, and fixes a latent ordering bug while doing so: the old worklist popped
LIFO, so an artist's albums were flattened in *reverse* order.
`tidaldy` overrides the method: playlist and album paths stream one chunk
per fetched page instead of paginating to exhaustion first (a new
`make_paginated_request` variant hands each page to a sink); everything else
follows the generic walk. Search-term track lists are a single page already.
The playlist arm also stops fetching the playlist metadata (title) — the
resolve needs only tracks.
`flume` is already a workspace dependency; `crabidy-core` adopts it for the
trait signature.
### D4 — Neither event loop blocks: spawned resolves, single-writer queue
Options considered:
1. Consume chunks inline in the playback loop's `handle_command` (loop still
blocked for the whole resolve; pause/next dead — today's hidden defect).
2. Spawn the resolve; queue mutations travel back to the playback loop as
internal commands, so the loop stays the **single writer** of queue state.
**Decision: (2), on both loops.**
- **Provider side**: `ProviderOrchestrator::run` wraps the orchestrator in an
`Arc`; the `ResolveTracks` arm spawns the resolve onto its own task instead
of awaiting it inline. Without this, the first chunk would deadlock the
system: playback applies chunk 1 → `play()` → sends `GetTrackUrls` and
awaits the reply — but the provider loop would still be busy resolving.
Other provider commands keep flowing while (possibly several) resolves run.
- **Playback side**: each queue op registers a *pending op* (id from an
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
drives `ProviderCommand::ResolveTracks` per path (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).

View File

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

View File

@ -1,154 +0,0 @@
# Roles and rights (basic-auth authorization)
crabidy listens on `0.0.0.0:50051`: anyone on the network can control
playback and — worse — rename or delete library stores. The owner wants
to hand out *limited* remotes: a co-host who may run the queue but not
touch the library, and guests who may only add tracks.
## Problem statement
Three roles, credentialed by password hashes in the server config:
- **owner** — the normal user; everything.
- **queue-owner** — anything on the queue (and playback), but no
library writes: no `w`, no `W`.
- **queue-appender** — may only add tracks to the end of the queue;
no removal, no reordering, no queue settings.
## Assumptions (confirmed by the request, or decided here)
- Transport stays plain HTTP/2 gRPC. Basic auth over cleartext is
acceptable on a trusted home network; anything else (Internet
exposure) needs TLS termination in front (reverse proxy, VPN) and is
out of scope. The README says so.
- 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,10 +5,7 @@ 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

View File

@ -1,8 +1,5 @@
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};

View File

@ -5,19 +5,13 @@ 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 {
@ -31,12 +25,8 @@ 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, engine_tap) { let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) {
Err(e) => { Err(e) => {
error!("Could not initialize player: {}", e); error!("Could not initialize player: {}", e);
return; return;
@ -49,17 +39,11 @@ 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
@ -124,15 +108,6 @@ 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

View File

@ -10,28 +10,12 @@ 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);
@ -47,7 +31,6 @@ 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
@ -95,16 +78,6 @@ 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 {
@ -112,7 +85,6 @@ 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")?;
@ -129,10 +101,6 @@ 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,
@ -143,10 +111,6 @@ 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,
}) })
} }
@ -181,7 +145,6 @@ 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),
} }
@ -204,7 +167,7 @@ impl PlayerEngine {
}); });
} }
#[instrument(skip_all, fields(source = %display_source(source_str)))] #[instrument(skip(self))]
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> { pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
self.reset(); self.reset();
@ -231,19 +194,12 @@ 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!( trace!(%url, "opening network stream");
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::<WindowedHttpStream>( StreamDownload::new_http(
params, url.clone(),
TempStorageProvider::new(), TempStorageProvider::new(),
Settings::default(), Settings::default(),
), ),
@ -252,25 +208,14 @@ 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();
// Mirror the played audio into the spectrum tap (it only self.sink.append(decoder);
// 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())),
@ -291,8 +236,7 @@ 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 self.sink.append(decoder);
.append(TappingSource::new(decoder, self.spectrum.clone()));
duration duration
} }
}; };
@ -394,37 +338,13 @@ 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()
} }
/// Toggles mute by zeroing the sink volume and remembering the level pub fn set_volume(&mut self, volume: f32) -> f32 {
/// to restore. Returns the new muted state. self.sink.set_volume(volume.clamp(0.0, 1.1));
pub fn toggle_mute(&mut self) -> bool { self.sink.volume()
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) {
@ -460,22 +380,3 @@ 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"
);
}
}

View File

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

View File

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

View File

@ -4,7 +4,6 @@ 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
@ -14,7 +13,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, features = ["channel", "codegen"] } tonic.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View File

@ -1,851 +0,0 @@
//! 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");
}
}

View File

@ -1,227 +0,0 @@
//! The help modal: a centered overlay listing usage notes and all key
//! bindings, rendered entirely from [`super::bindings::BINDINGS`].
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use super::bindings::{key_label, Scope, BINDINGS};
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
/// The modal's content: a usage blurb, two binding columns (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());
}
}
}

View File

@ -12,21 +12,14 @@ use ratatui::{
use crabidy_core::proto::crabidy::LibraryNode; use crabidy_core::proto::crabidy::LibraryNode;
use super::{ use super::{
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
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>,
@ -37,76 +30,14 @@ 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
@ -116,12 +47,10 @@ impl Library {
.collect(), .collect(),
); );
} }
// Marks are gated on is_queable when set; the bare selection must be if let Some(idx) = self.list_state.selected() {
// gated here too, or Enter on a plain folder ships a path the server return Some(vec![self.list[idx].path.to_string()]);
// can only resolve to nothing (silently ignored, like % / e / d on }
// items without the capability). None
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() {
@ -129,7 +58,8 @@ impl Library {
} }
} }
pub fn dive(&mut self) { pub fn dive(&mut self) {
if let Some(item) = self.resolved() { if let Some(idx) = self.list_state.selected() {
let item = &self.list[idx];
if let UiItemKind::Node = item.kind { if let UiItemKind::Node = item.kind {
let _ = self let _ = self
.tx .tx
@ -173,12 +103,8 @@ 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(real) = self if let Some(idx) = self.list_state.selected() {
.list_state let item = &mut self.list[idx];
.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;
} }
@ -194,9 +120,7 @@ impl Library {
} }
} }
pub fn update(&mut self, node: LibraryNode) { pub fn update(&mut self, node: LibraryNode) {
// Creatable nodes (e.g. an empty search node) must be enterable even if node.tracks.is_empty() && node.children.is_empty() {
// 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;
} }
@ -204,100 +128,55 @@ 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.is_creatable = node.is_creatable;
// Most nodes carry either children or tracks; search term nodes
// carry both (track results + artist/album results), so the list is
// the concatenation. Tracks first: they are the primary search hits
// (architecture/search.md fixes the order tracks, artists, albums).
self.list = node
.tracks
.iter()
.map(|t| UiItem {
path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: false,
is_queable: true,
is_creatable: false,
is_editable: false,
// Tracks carry no wire flags of their own: they inherit
// their node's blessing (architecture/captures.md D4).
is_deletable: node.tracks_deletable,
is_downloadable: node.is_downloadable,
is_skipped: t.is_skipped,
})
.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();
// 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.select(Some(self.prev_selected()));
if !node.tracks.is_empty() {
self.list = node
.tracks
.iter()
.map(|t| UiItem {
path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: false,
is_queable: true,
})
.collect();
} else {
// if tracks not empty use tracks instead
self.list = node
.children
.iter()
.map(|c| UiItem {
path: c.path.clone(),
title: c.title.clone(),
kind: UiItemKind::Node,
marked: false,
is_queable: c.is_queable,
})
.collect();
}
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
.filter .list
.visible()
.iter() .iter()
.map(|&real| &self.list[real]) .map(|i| {
.enumerate() let text = if i.marked {
.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()
}; };
if i.is_creatable { let style = if i.marked {
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();
@ -312,14 +191,7 @@ impl Library {
} else { } else {
COLOR_PRIMARY_DARK COLOR_PRIMARY_DARK
})) }))
.title(if let Some(query) = self.filter.query() { .title(self.title.clone()),
// 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()
@ -337,21 +209,15 @@ impl Library {
impl StatefulList for Library { impl StatefulList for Library {
fn get_size(&self) -> usize { fn get_size(&self) -> usize {
// Navigation operates on the filtered (visible) view. self.list.len()
self.filter.view_len()
} }
fn select(&mut self, idx: Option<usize>) { fn select(&mut self, idx: Option<usize>) {
// Remember the cursor per node as a real index so it survives a if let Some(pos) = idx {
// filter (which only reorders the view). With no filter, the self.positions
// view index is already the real index. .entry(self.path.clone())
if let Some(view) = idx { .and_modify(|e| *e = pos)
if let Some(real) = self.filter.to_real(view) { .or_insert(pos);
self.positions
.entry(self.path.clone())
.and_modify(|e| *e = real)
.or_insert(real);
}
} }
self.list_state.select(idx); self.list_state.select(idx);
} }

View File

@ -1,75 +1,3 @@
/// 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

View File

@ -12,37 +12,7 @@ use ratatui::{
Frame, Frame,
}; };
use super::{COLOR_PRIMARY, COLOR_SECONDARY}; use super::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,
@ -50,13 +20,6 @@ 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 {
@ -67,9 +30,6 @@ 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,
} }
} }
} }
@ -97,11 +57,7 @@ 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()
@ -114,39 +70,11 @@ 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(constraints) .constraints([Constraint::Max(8), Constraint::Max(1)])
.split(area); .split(area);
let media_info_text = if let Some(track) = &self.track { let media_info_text = if let Some(track) = &self.track {
@ -161,10 +89,8 @@ 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.shuffle, self.modifiers.repeat
self.modifiers.repeat,
if self.muted { ", Muted" } else { "" },
); );
vec![ vec![
Line::from(Span::raw(mods)), Line::from(Span::raw(mods)),
@ -216,16 +142,10 @@ 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 position.as_secs_f64().div(duration.as_secs_f64())
.as_secs_f64()
.div(duration.as_secs_f64())
.clamp(0.0, 1.0)
}; };
let progress = LineGauge::default() let progress = LineGauge::default()
@ -258,131 +178,5 @@ 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));
} }
} }

View File

@ -10,24 +10,13 @@ use ratatui::{
use crabidy_core::proto::crabidy::Queue as QueueData; use crabidy_core::proto::crabidy::Queue as QueueData;
use super::{ use super::{
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED,
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>,
} }
@ -37,35 +26,9 @@ 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);
} }
@ -73,19 +36,15 @@ 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_position() { if let Some(pos) = self.selected() {
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) {
// Map the real playing position to its view row; if the filter self.select(Some(self.current_position));
// 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_position() { if let Some(pos) = self.selected() {
// 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]));
} }
@ -95,7 +54,6 @@ 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()
@ -105,74 +63,33 @@ 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 selected = self.list_state.selected(); let queue_items: Vec<ListItem> = self
// Render only the visible rows; `view` is the rendered index the .list
// 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, (real, item))| { .map(|(idx, item)| {
let active = real == self.current_position; let active = idx == 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 mut style = if active { let 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(
@ -184,10 +101,7 @@ impl Queue {
} else { } else {
COLOR_PRIMARY_DARK COLOR_PRIMARY_DARK
})) }))
.title(match self.filter.query() { .title("Queue"),
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
@ -201,8 +115,7 @@ impl Queue {
impl StatefulList for Queue { impl StatefulList for Queue {
fn get_size(&self) -> usize { fn get_size(&self) -> usize {
// Navigation operates on the filtered (visible) view. self.list.len()
self.filter.view_len()
} }
fn select(&mut self, idx: Option<usize>) { fn select(&mut self, idx: Option<usize>) {
@ -213,207 +126,3 @@ 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"
);
}
}

View File

@ -14,23 +14,4 @@ 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,
} }

View File

@ -1,317 +0,0 @@
//! The cbd-tui client as a library: the server-facing orchestration loop
//! and the terminal UI loop, exposed as [`run`] so both the standalone
//! `cbd-tui` binary and the bundled `cbd` binary can host them
//! (architecture/cbd-bundle.md D1). Tracing setup stays with the
//! binaries — where logs go is a hosting decision.
pub mod app;
pub mod config;
pub mod rpc;
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();
}

View File

@ -1,11 +1,33 @@
//! The standalone TUI binary: file-based tracing (the terminal belongs mod app;
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives mod config;
//! in the library so the bundled `cbd` binary can host it too mod rpc;
//! (architecture/cbd-bundle.md D1).
use std::sync::OnceLock; use std::{
error::Error,
io,
sync::OnceLock,
time::{Duration, Instant},
};
use cbd_tui::config::Config; use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
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();
@ -45,5 +67,316 @@ 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();
} }

View File

@ -1,21 +1,16 @@
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, ClearQueueRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse,
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest,
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
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, Status, Streaming, Request, Streaming,
}; };
// FIXME: use anyhow + thiserror // FIXME: use anyhow + thiserror
@ -34,78 +29,16 @@ 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: Client, client: CrabidyServiceClient<Channel>,
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( pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
server: &'static crate::config::ServerConfig, let endpoint = Endpoint::from_static(addr).connect_lazy();
) -> Result<RpcClient, Box<dyn Error>> { let mut client = CrabidyServiceClient::new(endpoint);
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();
@ -117,7 +50,9 @@ impl RpcClient {
}) })
} }
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> { async fn get_update_stream(
client: &mut CrabidyServiceClient<Channel>,
) -> Streaming<GetUpdateStreamResponse> {
loop { 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 {
@ -142,7 +77,7 @@ impl RpcClient {
&mut self, &mut self,
path: &str, path: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> { ) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if is_cacheable(path) && self.library_node_cache.contains_key(path) { if 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 {
@ -153,8 +88,6 @@ 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));
@ -162,85 +95,6 @@ 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?;
@ -280,27 +134,6 @@ 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?;
@ -363,52 +196,3 @@ 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"));
}
}

1
cbd-web/.gitignore vendored
View File

@ -1 +0,0 @@
/dist

View File

@ -1,30 +0,0 @@
[package]
name = "cbd-web"
version.workspace = true
edition.workspace = true
[dependencies]
crabidy-core.workspace = true
leptos.workspace = true
# The browser-only half: transport, DOM glue, storage. Kept
# target-specific so the native build (which runs the unit tests for
# the pure state/keymap logic) stays free of wasm-only crates.
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook.workspace = true
futures.workspace = true
gloo-timers.workspace = true
tonic = { workspace = true, features = ["codegen"] }
tonic-web-wasm-client.workspace = true
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [
"Document",
"Element",
"HtmlInputElement",
"KeyboardEvent",
"Location",
"Performance",
"Storage",
"Window",
] }

View File

@ -1,86 +0,0 @@
# cbd-web — the browser client
A [Leptos](https://leptos.dev) client-side WASM app with the same
functionality as `cbd-tui`, served by `crabidy-server` itself. 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`).

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,391 +0,0 @@
//! Keyboard bindings — the web port of `cbd-tui/src/app/bindings.rs`,
//! keyed by browser `KeyboardEvent` values instead of crossterm codes.
//! Deliberate differences: there is no `q` (quit) in a browser tab, and
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
use 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"));
}
}

View File

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

View File

@ -1,302 +0,0 @@
//! gRPC-web transport: the same generated `crabidy-core` client the
//! TUI uses, over `tonic-web-wasm-client` against the origin that
//! served this app (architecture/web-client.md). Credentials, when the
//! server requires them, ride as the same `authorization: Basic`
//! header the TUI sends; the header value is never logged.
use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, 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(())
}
}

View File

@ -1,517 +0,0 @@
//! Pure client state — the web port of the TUI's pane logic
//! (`cbd-tui/src/app/{library,queue,mod}.rs`), free of DOM and
//! transport so it unit-tests on the native target. Components own
//! these values inside Leptos signals and call the methods on updates.
use std::collections::HashMap;
use crabidy_core::proto::crabidy::{CaptureProgress, LibraryNode, Track};
/// Which pane has keyboard focus (`Tab` toggles, like the TUI).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
Library,
Queue,
}
/// Why the one-line name dialog is open — the web port of the TUI's
/// `InputPurpose`, deciding the submit RPC and the dialog label.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NamePurpose {
/// `%`: create a child (search term) under the creatable node.
Create { parent_path: String },
/// `e`: rename the node at `path` (prefilled with its title).
Rename { path: String },
/// `w` in the queue pane: save the queue under the entered name.
SaveQueue,
/// `w`/`W` in the library: bookmark or download-capture `path`.
Capture { path: String, download: bool },
}
impl NamePurpose {
/// The dialog label; capture warns about duration like the TUI.
pub fn label(&self) -> &'static str {
match self {
NamePurpose::Create { .. } => "new node",
NamePurpose::Rename { .. } => "rename",
NamePurpose::SaveQueue => "save queue",
NamePurpose::Capture {
download: false, ..
} => "bookmark",
NamePurpose::Capture { download: true, .. } => "capture (slow, resumable)",
}
}
}
/// A modal dialog. At most one is open; while one is open, keys go to
/// it (the keymap is bypassed, mirroring the TUI's modal overlays).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Dialog {
/// Text input with a purpose-dependent submit.
Name {
purpose: NamePurpose,
buffer: String,
},
/// 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");
}
}

View File

@ -1,481 +0,0 @@
/* crabidy web client pure modern CSS (architecture/web-client.md).
One accent variable (crab orange-red), light and dark themes via
color-scheme + light-dark(); the theme toggle stamps data-theme on
<html>, otherwise the OS decides. No frameworks, no external
requests everything ships in the bundle. */
:root {
color-scheme: light dark;
/* The crab. Every accent tone derives from this one value. */
--accent: oklch(0.62 0.19 35);
--accent-strong: color-mix(in oklch, var(--accent) 85%, black);
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--on-accent: oklch(0.99 0.005 60);
--bg: light-dark(oklch(0.98 0.005 60), oklch(0.17 0.01 260));
--bg-raised: light-dark(oklch(1 0 0), oklch(0.21 0.012 260));
--fg: light-dark(oklch(0.25 0.015 260), oklch(0.92 0.005 60));
--fg-dim: light-dark(oklch(0.52 0.012 260), oklch(0.68 0.008 60));
--danger: light-dark(oklch(0.54 0.2 25), oklch(0.68 0.19 25));
--border: color-mix(in oklch, var(--fg) 14%, transparent);
--shadow: 0 8px 32px light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.55));
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 15px/1.45 system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
overscroll-behavior: none;
}
button {
font: inherit;
color: inherit;
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: 6px;
padding: 0.3rem 0.75rem;
cursor: pointer;
&:hover {
background: var(--accent-strong);
}
&:disabled {
opacity: 0.35;
cursor: default;
}
&.ghost {
background: transparent;
color: var(--fg-dim);
padding: 0.25rem 0.5rem;
&:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--fg);
}
&.active {
color: var(--accent);
background: var(--accent-soft);
}
}
&.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;
}
}
}

View File

@ -1,14 +0,0 @@
[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

View File

@ -1,189 +0,0 @@
//! `cbd`: server and TUI bundled into one binary
//! (see `architecture/cbd-bundle.md`).
//!
//! Starting `cbd` starts the crabidy server in-process, waits for it to
//! accept connections, and runs the TUI against it — the same configs,
//! the same localhost gRPC wire as the standalone pair. If a server is
//! already listening (a standalone `crabidy-server`), `cbd` adopts it
//! instead of failing. Quitting the TUI ends the process, and with it
//! the in-process server; the current queue is persisted continuously,
//! so the next start restores it.
use std::error::Error;
use std::sync::OnceLock;
use std::time::Duration;
use cbd_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();
}
}

View File

@ -5,28 +5,14 @@ edition.workspace = true
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
flume.workspace = true clap-serde-derive.workspace = true
percent-encoding.workspace = true dirs.workspace = true
prost.workspace = true prost.workspace = true
serde.workspace = true serde.workspace = true
toml.workspace = true toml.workspace = true
# Codegen only: the generated client/server stubs need no transport, tonic.workspace = true
# 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

View File

@ -1,10 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
// No `connect()` convenience impl: it hardcodes tonic::transport, tonic_prost_build::compile_protos("crabidy/v1/crabidy.proto")?;
// 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(())
} }

View File

@ -7,32 +7,6 @@ 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);
@ -77,50 +51,6 @@ 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;
@ -180,43 +110,9 @@ 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 {}
@ -246,14 +142,6 @@ 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 {
@ -266,10 +154,6 @@ 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 {
@ -303,9 +187,6 @@ 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 {
@ -316,12 +197,4 @@ 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;
} }

View File

@ -1,4 +1,3 @@
#[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,
@ -6,7 +5,6 @@ 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};
@ -30,110 +28,15 @@ 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, PartialEq, Eq)] #[derive(Clone, Debug, Hash)]
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,
} }
@ -175,32 +78,6 @@ 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 {
@ -210,27 +87,16 @@ 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,
} }
} }
} }
@ -239,7 +105,6 @@ 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,
@ -298,298 +163,4 @@ 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);
}
} }

View File

@ -7,47 +7,19 @@ 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, features = ["router", "transport", "codegen"] } tonic.workspace = true
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

View File

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

View File

@ -1,517 +0,0 @@
//! Role-based authorization for the gRPC surface
//! (architecture/roles-auth.md).
//!
//! Enforcement lives in exactly one place: [`AuthLayer`], a tower layer
//! in front of the tonic service. It authenticates the HTTP basic-auth
//! header against the configured role hashes and checks the resulting
//! [`Role`] against the method's [`minimum_role`] — *before* any
//! handler runs, default-deny for methods it does not know. Handlers
//! never see unauthorized requests and did not change for this feature.
//!
//! Credentials never appear in logs or error messages.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use base64::Engine;
use futures::future::{ready, Either, Ready};
use tonic::Status;
use tracing::warn;
use crate::settings::AuthSettings;
/// The three roles, ordered by privilege: every role includes the
/// rights of the roles below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Role {
/// May read and append tracks to the queue (plus create search
/// terms — the mechanism of finding something to append).
QueueAppender,
/// Anything on the queue and playback, but no library writes.
QueueOwner,
/// The normal user: everything.
Owner,
}
impl Role {
/// The basic-auth user name selecting this role.
fn from_user(user: &str) -> Option<Role> {
match user {
"owner" => Some(Role::Owner),
"queue-owner" => Some(Role::QueueOwner),
"queue-appender" => Some(Role::QueueAppender),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::QueueOwner => "queue-owner",
Role::QueueAppender => "queue-appender",
}
}
}
/// The gRPC path prefix of our service's methods.
const SERVICE_PREFIX: &str = "/crabidy.v1.CrabidyService/";
/// Minimum role required for a gRPC request path (the rights matrix,
/// architecture/roles-auth.md). Unknown methods — including anything
/// outside our service — require [`Role::Owner`]: fail-closed, a
/// future RPC starts locked until it is mapped here (a test pins the
/// full method list, so forgetting fails the suite).
pub fn minimum_role(grpc_path: &str) -> Role {
let Some(method) = grpc_path.strip_prefix(SERVICE_PREFIX) else {
return Role::Owner;
};
match method {
// Reads, the one appender queue verb, and search-term creation.
"Init" | "GetLibraryNode" | "GetUpdateStream" | "Append" | "CreateLibraryNode" => {
Role::QueueAppender
}
// Every other queue and playback verb.
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "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);
}
}

View File

@ -1,324 +0,0 @@
//! 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);
}
}

View File

@ -1,828 +0,0 @@
//! 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;
}
}

View File

@ -1,783 +0,0 @@
//! 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
));
}
}

View File

@ -1,308 +1,7 @@
pub mod auth; use crabidy_core::proto::crabidy::{Queue, Track};
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, info, instrument, warn, Span}; use tracing::{debug, error};
/// 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 {
@ -325,10 +24,6 @@ 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,
} }
} }
} }
@ -358,16 +53,6 @@ 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
} }
@ -625,7 +310,6 @@ mod tests {
title: format!("track {id}"), title: format!("track {id}"),
duration: None, duration: None,
album: None, album: None,
is_skipped: false,
} }
} }
@ -706,69 +390,6 @@ 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);
@ -781,216 +402,3 @@ 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` D1D3) — 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",
}
}
}

View File

@ -1,47 +1,59 @@
//! The standalone server binary: stderr tracing plus use audio_player::PlayerMessage;
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack use crabidy_core::proto::crabidy::{
//! lives in the library so the bundled `cbd` binary can host it too crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
//! (architecture/cbd-bundle.md D1). };
use crabidy_core::{ProviderClient, ProviderError};
use clap::Parser; use tracing::{debug, error, info, instrument, warn, Span};
use tracing_subscriber::{prelude::*, EnvFilter}; use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser)] mod playback;
#[command(author, version, about)] use playback::Playback;
struct Cli { mod provider;
#[command(subcommand)] use provider::ProviderOrchestrator;
command: Option<Command>, mod rpc;
} use rpc::RpcService;
#[derive(clap::Subcommand)] use tonic::{transport::Server, Result};
enum Command {
/// Hash a password for the `[auth]` section of crabidy-server.toml const LISTEN_ADDR: &str = "0.0.0.0:50051";
/// (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 + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}
/// Reads one line from stdin and prints its argon2 PHC hash. let (update_tx, _) = tokio::sync::broadcast::channel(2048);
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
let mut password = String::new(); error!("failed to init provider orchestrator: {err}");
std::io::stdin().read_line(&mut password)?; err
let password = password.trim_end_matches(['\r', '\n']); })?;
if password.is_empty() {
return Err("empty password".into()); let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
}
println!("{}", crabidy_server::auth::hash_password(password)?); 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");
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(())
} }
@ -55,7 +67,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,ytdy=debug,audio_player=debug", "info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
) )
}); });
@ -74,3 +86,181 @@ 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",
}
}
}

View File

@ -1,17 +1,14 @@
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, get_update_stream_response::Update as StreamUpdate, InitResponse, PlayState, QueueTrack, Track,
Queue as ProtoQueue, QueueTrack, Track, TrackPosition, TrackPosition,
}; };
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::collections::HashMap; use crabidy_server::QueueManager;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex;
use std::sync::{Arc, Mutex}; use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
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>,
@ -20,17 +17,6 @@ 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,
} }
@ -38,12 +24,10 @@ 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,
@ -52,52 +36,11 @@ 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
@ -135,9 +78,7 @@ impl Playback {
*play_state *play_state
}; };
InitResponse { InitResponse {
// Snapshot with `resolving`: a client connecting queue: Some(queue.clone().into()),
// 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,
@ -156,34 +97,48 @@ impl Playback {
} }
PlaybackCommand::Replace { paths } => { PlaybackCommand::Replace { paths } => {
// A replace obsoletes whatever earlier ops are still let all_tracks = self.resolve_tracks(paths).await;
// resolving; their late chunks must not land in the new debug!(count = all_tracks.len(), "replacing queue");
// queue. let current = {
self.cancel_pending_resolves(); let Ok(mut queue) = self.queue.lock() else {
self.start_resolve(ResolveKind::Replace, paths);
}
PlaybackCommand::Queue { paths } => {
let position = {
let Ok(queue) = self.queue.lock() else {
error!("queue lock poisoned"); error!("queue lock poisoned");
return; return;
}; };
queue.current_position() as u32 queue.replace_with_tracks(&all_tracks);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
queue.current_track()
}; };
self.start_resolve(ResolveKind::InsertAfter(position), paths); self.play(current).await;
}
PlaybackCommand::Queue { paths } => {
let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "queueing after current");
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
};
let track = queue.queue_tracks(&all_tracks);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
track
};
self.play_if_some(track).await;
} }
PlaybackCommand::Append { paths } => { PlaybackCommand::Append { paths } => {
self.start_resolve(ResolveKind::Append, paths); let all_tracks = self.resolve_tracks(paths).await;
} debug!(count = all_tracks.len(), "appending to queue");
let track = {
PlaybackCommand::ApplyResolvedChunk { op_id, tracks } => { let Ok(mut queue) = self.queue.lock() else {
self.apply_resolved_chunk(op_id, tracks).await; error!("queue lock poisoned");
} return;
};
PlaybackCommand::ResolveFinished { op_id } => { let track = queue.append_tracks(&all_tracks);
self.finish_resolve(op_id); self.broadcast(StreamUpdate::Queue(queue.clone().into()));
track
};
self.play_if_some(track).await;
} }
PlaybackCommand::Remove { positions } => { PlaybackCommand::Remove { positions } => {
@ -195,7 +150,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_queue(&queue); self.broadcast(StreamUpdate::Queue(queue.clone().into()));
(track, was_last) (track, was_last)
}; };
let state = { let state = {
@ -217,21 +172,29 @@ impl Playback {
} }
PlaybackCommand::Insert { position, paths } => { PlaybackCommand::Insert { position, paths } => {
self.start_resolve(ResolveKind::InsertAfter(position), paths); let all_tracks = self.resolve_tracks(paths).await;
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_queue(&queue); self.broadcast(StreamUpdate::Queue(queue.clone().into()));
should_stop should_stop
}; };
if should_stop { if should_stop {
@ -252,39 +215,6 @@ 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 {
@ -296,7 +226,6 @@ 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");
@ -310,7 +239,6 @@ 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");
@ -352,13 +280,10 @@ impl Playback {
}; };
} }
PlaybackCommand::ToggleMute => match self.player.toggle_mute().await { PlaybackCommand::ToggleMute => {
Ok(muted) => { // FIXME: implement mute in the player engine
debug!(muted, "toggled mute"); debug!("toggle mute requested (not implemented)");
self.broadcast(StreamUpdate::Mute(muted)); }
}
Err(err) => warn!("toggle_mute failed: {err:?}"),
},
PlaybackCommand::Next => { PlaybackCommand::Next => {
let track = { let track = {
@ -435,192 +360,32 @@ impl Playback {
} }
} }
/// Registers a pending resolve operation and spawns its forwarder task. /// Resolves a mixed list of track and node paths into tracks by asking
/// /// the provider orchestrator.
/// The forwarder resolves `paths` one after the other (preserving the async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
/// request's path order): for each path it sends let mut all_tracks = Vec::new();
/// `ProviderCommand::ResolveTracks` with a fresh bounded chunk channel for path in paths {
/// and forwards every chunk to the playback loop as let (result_tx, result_rx) = flume::bounded(1);
/// `PlaybackCommand::ApplyResolvedChunk`; after the last path it sends let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
/// `ResolveFinished`. When the op's cancellation flag is set, the path: path.clone(),
/// forwarder drops the chunk receiver instead — the provider's next result_tx,
/// send fails and the fetch stops. Queue state is never touched here: });
/// mutations happen only when the loop processes the forwarded if let Err(err) = self.provider_tx.send_async(message).await {
/// commands. An immediate `Queue` broadcast (unchanged tracks, error!("provider channel closed: {err}");
/// `resolving = true`) gives clients instant feedback. return all_tracks;
fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) { }
let op = PendingResolve::new(kind); match result_rx.recv_async().await {
let cancelled = op.cancel_flag(); Ok(tracks) => {
let op_id = self.next_op_id.fetch_add(1, Ordering::Relaxed); if tracks.is_empty() {
{
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 {
path: path.clone(),
chunk_tx,
});
if provider_tx.send_async(message).await.is_err() {
error!("provider channel closed");
break;
}
let mut forwarded = 0usize;
while let Ok(tracks) = chunk_rx.recv_async().await {
// 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);
} }
// Always reported — also for cancelled or empty ops — so Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"),
// 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))]
@ -663,76 +428,43 @@ impl Playback {
} }
} }
/// Finds the stream URLs of the first playable track, starting at /// Starts playback of the given track. When fetching stream URLs fails
/// `track` and advancing the queue past unplayable ones. Tracks marked /// the failing track is skipped and playback continues with the next
/// `is_skipped` (captures recorded their source as uncapturable) are /// track in the queue.
/// 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 Some(urls) = self.next_playable_urls(track).await else { let mut path = track.path.clone();
self.stop_player().await; let urls = loop {
return; 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;
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(),
@ -744,177 +476,3 @@ 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);
}
}

View File

@ -1,6 +1,3 @@
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::{
@ -15,121 +12,21 @@ 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 {
// Behind an Arc so long-running resolves can be spawned onto while let Ok(ProviderMessage { span, command }) = self.provider_rx.recv_async().await {
// 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());
Arc::clone(&this) self.handle_command(command).instrument(handler_span).await;
.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: Arc<Self>, command: ProviderCommand) { async fn handle_command(&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;
@ -143,109 +40,47 @@ impl ProviderOrchestrator {
error!("failed to send get_track_urls result: {err}"); error!("failed to send get_track_urls result: {err}");
} }
} }
ProviderCommand::ResolveTracks { path, chunk_tx } => { ProviderCommand::ResolveTracks { path, result_tx } => {
// Spawned: a large resolve must not block this loop, or the let result = self.resolve_tracks(&path).await;
// 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 create_library_node result: {err}"); error!("failed to send resolve_tracks 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 } => {
let result = self.delete_lib_node(&path).await;
if let Err(err) = result_tx.send_async(result).await {
error!("failed to send delete_library_node result: {err}");
}
}
ProviderCommand::CaptureLibraryNode {
path,
name,
download,
progress_tx,
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}");
}
return;
}
if let Err(err) = result_tx.send_async(Ok(())).await {
error!("failed to send capture_library_node result: {err}");
}
let progress = crate::capture::Progress::new(&name, download, progress_tx);
let result = if download {
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 let Err(err) = &result {
warn!(path, name, download, "cannot capture subtree: {err}");
}
progress
.finish(result.err().map(|err| err.to_string()))
.await;
}
.in_current_span(),
);
}
} }
} }
/// Resolves a path into playable tracks. A track path resolves to that
/// single track; a node path is flattened by walking its queueable
/// descendants.
#[instrument(skip(self))]
async fn resolve_tracks(&self, path: &str) -> Vec<Track> {
if self.is_track_path(path) {
return match self.get_metadata_for_track(path).await {
Ok(track) => vec![track],
Err(err) => {
warn!(path, "failed to resolve track: {err}");
Vec::new()
}
};
}
let mut tracks = Vec::new();
let mut nodes_to_go = vec![path.to_string()];
while let Some(node_path) = nodes_to_go.pop() {
let node = match self.get_lib_node(&node_path).await {
Ok(node) => node,
Err(err) => {
warn!(node = node_path, "skipping unreadable node: {err}");
continue;
}
};
if node.is_queable {
tracks.extend(node.tracks);
nodes_to_go.extend(node.children.into_iter().map(|c| c.path))
}
}
debug!(count = tracks.len(), "resolved path into tracks");
tracks
}
} }
#[async_trait] #[async_trait]
@ -276,132 +111,11 @@ 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,
}) })
} }
@ -414,36 +128,6 @@ 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
} }
@ -452,30 +136,6 @@ 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)
} }
@ -485,33 +145,6 @@ 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)
} }
@ -521,37 +154,6 @@ 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
} }
@ -564,180 +166,7 @@ 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)
}
} }

View File

@ -1,472 +0,0 @@
//! 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;
}
}
}

View File

@ -1,22 +1,15 @@
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, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse, AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse, ClearQueueResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, GetUpdateStreamRequest,
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, InsertResponse, NextRequest,
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, NextResponse, PrevRequest, PrevResponse, QueueRequest, QueueResponse, RemoveRequest,
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, RemoveResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RestartTrackResponse,
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest, SaveQueueRequest, SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest,
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
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};
@ -106,136 +99,6 @@ 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,
@ -376,115 +239,13 @@ impl CrabidyService for RpcService {
Ok(Response::new(Box::pin(output_stream))) Ok(Response::new(Box::pin(output_stream)))
} }
/// Captures a queueable subtree as a bookmark via the provider loop #[instrument(skip(self, _request))]
/// (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> {
let name = request.into_inner().name; debug!("received save_queue request (not implemented)");
tracing::Span::current().record("name", name.as_str()); Ok(Response::new(SaveQueueResponse {}))
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))]

View File

@ -1,106 +0,0 @@
//! 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}");
}
}
}

View File

@ -1,162 +0,0 @@
//! 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:?}");
}
}

View File

@ -1,133 +0,0 @@
//! 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");
}
}

View File

@ -1,196 +0,0 @@
//! 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();
}

View File

@ -14,26 +14,11 @@ 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;
}; };
@ -52,19 +37,6 @@ 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/

View File

@ -1,20 +0,0 @@
[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"] }

View File

@ -1,67 +0,0 @@
# 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

View File

@ -1,51 +0,0 @@
# 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.

View File

@ -1,54 +0,0 @@
# 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).

View File

@ -1,28 +0,0 @@
# 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`.

View File

@ -1,56 +0,0 @@
# 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.

View File

@ -1,70 +0,0 @@
# 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.

View File

@ -1,32 +0,0 @@
# 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`.

View File

@ -1,83 +0,0 @@
# 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`.

View File

@ -1,94 +0,0 @@
# 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`.

View File

@ -1,68 +0,0 @@
# 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.

View File

@ -1,39 +0,0 @@
# 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.

View File

@ -1,130 +0,0 @@
# 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`.

View File

@ -1,806 +0,0 @@
# 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.

View File

@ -1,60 +0,0 @@
# 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.

View File

@ -1,45 +0,0 @@
# 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`.

View File

@ -1,38 +0,0 @@
# 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.

View File

@ -1,73 +0,0 @@
# 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).

View File

@ -1,84 +0,0 @@
# 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).

View File

@ -1,36 +0,0 @@
# 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.

View File

@ -1,59 +0,0 @@
# 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`.

View File

@ -1,57 +0,0 @@
# 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.

View File

@ -1,66 +0,0 @@
# 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.

View File

@ -1,91 +0,0 @@
# 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.

View File

@ -1,95 +0,0 @@
# 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.

View File

@ -1,92 +0,0 @@
# 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