Compare commits
33 Commits
9706e3b5d2
...
ef69afdd5f
| Author | SHA1 | Date |
|---|---|---|
|
|
ef69afdd5f | |
|
|
80c4b6e7ed | |
|
|
7f34f869a9 | |
|
|
bdfc0ec29c | |
|
|
b3fec6d3c0 | |
|
|
0b9970b550 | |
|
|
572be04206 | |
|
|
21fc4dc15a | |
|
|
550237f69a | |
|
|
435af91d9c | |
|
|
4e59e50943 | |
|
|
1c83217745 | |
|
|
383173046d | |
|
|
c84dec9ca2 | |
|
|
973bb7bbc6 | |
|
|
6f787285bf | |
|
|
214dece19f | |
|
|
58f7f9c66b | |
|
|
8032bec8e1 | |
|
|
c6e9f71cf4 | |
|
|
7f0e900c56 | |
|
|
655e8054b8 | |
|
|
4e44f672e8 | |
|
|
20e071f0af | |
|
|
8bb653ab3e | |
|
|
955c7ea5b4 | |
|
|
4dd5f01217 | |
|
|
ccab43a133 | |
|
|
333c6e040a | |
|
|
d741523e53 | |
|
|
8586194096 | |
|
|
5d2f88aa98 | |
|
|
f783e8152b |
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
34
Cargo.toml
|
|
@ -2,10 +2,14 @@
|
|||
resolver = "2"
|
||||
members = [
|
||||
"audio-player",
|
||||
"cbd",
|
||||
"cbd-tui",
|
||||
"cbd-web",
|
||||
"crabidy-core",
|
||||
"crabidy-server",
|
||||
"fsdy",
|
||||
"tidaldy",
|
||||
"ytdy",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -14,18 +18,28 @@ edition = "2021"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
axum = "0.8"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap-serde-derive = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
crossterm = "0.29"
|
||||
dirs = "6"
|
||||
flume = "0.12"
|
||||
futures = "0.3"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
http = "1"
|
||||
include_dir = "0.7"
|
||||
leptos = { version = "0.8", default-features = false, features = ["csr"] }
|
||||
notify-rust = "4"
|
||||
percent-encoding = "2"
|
||||
prost = "0.14"
|
||||
rand = "0.10"
|
||||
realfft = "3"
|
||||
ratatui = "0.30"
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
|
|
@ -40,6 +54,10 @@ rodio = { version = "0.22", default-features = false, features = [
|
|||
"playback",
|
||||
"symphonia-all",
|
||||
] }
|
||||
rustypipe = { version = "0.11", default-features = false, features = [
|
||||
"rustls-tls-webpki-roots",
|
||||
"userdata",
|
||||
] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
|
|
@ -48,13 +66,23 @@ stream-download = { version = "0.24", default-features = false, features = [
|
|||
"reqwest-rustls",
|
||||
"temp-storage",
|
||||
] }
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-stream = "0.1"
|
||||
toml = "1"
|
||||
tonic = "0.14"
|
||||
# default-features = false so crabidy-core can select codegen-only for
|
||||
# wasm builds (a member cannot *drop* workspace-inherited default
|
||||
# features); native binaries re-enable what they need.
|
||||
tonic = { version = "0.14", default-features = false }
|
||||
tonic-prost = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
tonic-web = "0.14"
|
||||
tonic-web-wasm-client = "0.9"
|
||||
tower = "0.5"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
|
@ -62,5 +90,9 @@ url = "2"
|
|||
|
||||
# Local crates
|
||||
audio-player = { path = "audio-player" }
|
||||
cbd-tui = { path = "cbd-tui" }
|
||||
crabidy-core = { path = "crabidy-core" }
|
||||
crabidy-server = { path = "crabidy-server" }
|
||||
fsdy = { path = "fsdy" }
|
||||
tidaldy = { path = "tidaldy" }
|
||||
ytdy = { path = "ytdy" }
|
||||
|
|
|
|||
189
README.md
189
README.md
|
|
@ -1 +1,190 @@
|
|||
# crabidy
|
||||
|
||||
A client/server music player. A headless gRPC server owns the library,
|
||||
the play queue, and audio output; a terminal UI connects to it over
|
||||
localhost (or the network). Media comes from pluggable **providers**,
|
||||
each mounted as a subtree of one library:
|
||||
|
||||
```text
|
||||
/
|
||||
├── tidal Tidal streaming (see tidaldy/README.md)
|
||||
├── youtube YouTube search & playlists (see ytdy/README.md)
|
||||
├── fs a local music folder (see fsdy/README.md)
|
||||
├── queues saved play queues (managed by the server)
|
||||
├── bookmarks link snapshots of library subtrees (`w`)
|
||||
└── captures downloaded snapshots with local audio (`W`)
|
||||
```
|
||||
|
||||
## Binaries
|
||||
|
||||
- `crabidy-server` — the server: providers, queue, playback, gRPC on
|
||||
`0.0.0.0:50051`. Also serves the web client at that address (see
|
||||
below).
|
||||
- `cbd-tui` — the terminal client. Press `?` inside for all key
|
||||
bindings.
|
||||
- `cbd` — both in one process: starts the server, waits until it
|
||||
accepts connections, then runs the TUI. Adopts an already-running
|
||||
server instead of failing on an occupied port.
|
||||
- `cbd-web` — the browser client (Leptos/WASM), with the same
|
||||
functionality as the TUI. Not run directly: it is built to a bundle
|
||||
and embedded into `crabidy-server` (see
|
||||
[cbd-web/README.md](cbd-web/README.md)).
|
||||
|
||||
## Quick start
|
||||
|
||||
The toolchain is managed by [devenv](https://devenv.sh):
|
||||
|
||||
```sh
|
||||
devenv shell # provides rust, yt-dlp, and friends
|
||||
cargo run -p cbd # server + TUI in one process
|
||||
```
|
||||
|
||||
Or run the halves separately: `cargo run -p crabidy-server` and, in
|
||||
another terminal, `cargo run -p cbd-tui`.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration lives in `~/.config/crabidy/` (the platform config
|
||||
directory). Every file is optional; missing providers simply do not
|
||||
mount. Files are created/rewritten on first start with their defaults
|
||||
filled in.
|
||||
|
||||
| File | Component | Documentation |
|
||||
| -------------------- | ---------- | -------------------------------------- |
|
||||
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
||||
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
||||
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
|
||||
| `cbd-tui.toml` | `cbd-tui` | below |
|
||||
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
|
||||
| `crabidy-server.toml`| server | below (never auto-created) |
|
||||
|
||||
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
|
||||
live in `~/.config/crabidy/`; they need no configuration and hold plain
|
||||
folders of track files in the format documented in
|
||||
[fsdy/README.md](fsdy/README.md).
|
||||
|
||||
### `cbd-tui.toml` and `cbd.toml`
|
||||
|
||||
Client configuration. `cbd-tui` (the standalone terminal client) reads
|
||||
`cbd-tui.toml`; `cbd` (server + TUI in one process) reads its own
|
||||
`cbd.toml`. They are **separate files with the same options** so the two
|
||||
can run side by side on one machine — a common setup is `cbd` playing
|
||||
locally against its in-process server while `cbd-tui` points at a remote
|
||||
server (e.g. a Raspberry Pi). A shared file would force one to follow
|
||||
the other's `address`.
|
||||
|
||||
```toml
|
||||
# Where to find the server. Default (both files): localhost, which is
|
||||
# what cbd's own in-process server listens on. Point cbd-tui.toml at a
|
||||
# remote server to use it as a remote control.
|
||||
address = "http://127.0.0.1:50051"
|
||||
|
||||
# Credentials, when the server has [auth] configured (see below).
|
||||
# `user` is the role name; leave both empty against an open server.
|
||||
# The password is stored in plaintext — keep this file private.
|
||||
user = ""
|
||||
password = ""
|
||||
|
||||
# Show the frequency-spectrum bars under the track progress. Default true.
|
||||
spectrum = true
|
||||
```
|
||||
|
||||
Every option is also available as a command-line flag
|
||||
(`cbd-tui --address ...`, `cbd --address ...`).
|
||||
|
||||
### `crabidy-server.toml` — roles and rights
|
||||
|
||||
By default the server is open: everyone who can reach the port has
|
||||
full control. Adding an `[auth]` section turns on HTTP basic auth for
|
||||
every RPC and hands out *roles* (see `architecture/roles-auth.md`):
|
||||
|
||||
- **owner** — everything (the normal user).
|
||||
- **queue-owner** — anything on the queue and playback, but no
|
||||
library writes: no bookmarks (`w`), captures (`W`), queue saving,
|
||||
renames or deletes.
|
||||
- **queue-appender** — may browse/search and append tracks to the
|
||||
queue; nothing else.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
# One PHC hash per role; omit a role to disable it. Generate with:
|
||||
# crabidy-server hash-password (reads the password from stdin)
|
||||
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
queue_owner = "$argon2id$..."
|
||||
queue_appender = "$argon2id$..."
|
||||
```
|
||||
|
||||
Clients authenticate with the role name as the basic-auth user (see
|
||||
`cbd-tui.toml` above). A malformed `crabidy-server.toml` aborts server
|
||||
startup rather than silently running open. Note that the transport is
|
||||
plain HTTP/2: fine on a trusted home network, but anything exposed
|
||||
further needs TLS termination (reverse proxy, VPN) in front.
|
||||
|
||||
## Using the library
|
||||
|
||||
Navigation is vim-style: `j`/`k` select, `l` enters the selected
|
||||
folder, `h` goes to the parent, `Tab` switches between library and
|
||||
queue, `Enter` replaces the queue with the selection. `%` creates a
|
||||
node where the pane title shows `% to add` (e.g. a search term), `e`
|
||||
renames, `d` deletes. `/` filters the current pane (library or queue)
|
||||
live as you type — `Enter` keeps the filter, `Esc` clears it.
|
||||
|
||||
- `w` saves the selection as a **bookmark** (links; needs the source
|
||||
provider to replay) or, in the queue pane, saves the queue.
|
||||
- `W` **captures** the selection: the subtree is mirrored under
|
||||
`/captures/<name>` with every track's audio downloaded next to its
|
||||
metadata — fully local playback afterwards. In the queue pane `W`
|
||||
captures the current queue directly (no need to save it first).
|
||||
Tracks that are already local (from `/fs` or another capture) are
|
||||
copied in rather than re-downloaded, so a queue mixing streamed and
|
||||
local tracks captures completely. Captures are incremental:
|
||||
re-capturing the same name resumes and completes it; tracks whose
|
||||
source genuinely cannot be captured are recorded as *skipped* (red in
|
||||
the UI, skipped by playback). Download captures can take long;
|
||||
progress is shown in the library pane. Inside `/captures`, `d` deletes
|
||||
any
|
||||
folder or single track *from disk* (audio included) after a `y/N`
|
||||
confirmation.
|
||||
|
||||
Press `?` for the full binding table.
|
||||
|
||||
A row of frequency-spectrum bars is drawn under the track progress
|
||||
while audio plays (the server taps its own output, runs the FFT, and
|
||||
streams the bars, so it works whether the server is local or remote).
|
||||
Turn it off with `spectrum = false` in the client config.
|
||||
|
||||
## Web client
|
||||
|
||||
`crabidy-server` serves a browser client with the same functionality as
|
||||
the TUI at its own address (`http://<server>:50051/`) — same navigation,
|
||||
same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, queue and playback
|
||||
controls, `?` for help), plus clickable equivalents and a light/dark
|
||||
theme toggle. It talks gRPC-web to the same service the TUI uses, so it
|
||||
honors the same `[auth]` roles (it shows a login form when the server
|
||||
requires credentials).
|
||||
|
||||
It is compiled to a WASM bundle and embedded into the server binary,
|
||||
behind the default-on `web-ui` cargo feature. A plain `cargo build`
|
||||
needs no WASM toolchain — it embeds a "not built" placeholder page until
|
||||
you build the bundle:
|
||||
|
||||
```sh
|
||||
devenv shell -- build-web # writes cbd-web/dist
|
||||
cargo build -p crabidy-server # embeds it
|
||||
```
|
||||
|
||||
Build the server with `--no-default-features` for a headless,
|
||||
gRPC-only binary. See [cbd-web/README.md](cbd-web/README.md) for the
|
||||
dev loop and details.
|
||||
|
||||
## Logs
|
||||
|
||||
`cbd` and `cbd-tui` log to `~/.local/state/crabidy/` (daily files);
|
||||
`crabidy-server` logs to stderr. Stream URLs and credentials are
|
||||
redacted from logs by design.
|
||||
|
||||
## Development
|
||||
|
||||
Design documents live in `architecture/`, per-feature quality gates in
|
||||
`quality/`, and implementation plans in `plan/`. See `CLAUDE.md` /
|
||||
`AGENTS.md` for the development workflow and coding rules.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
# Bookmarks: capturing library subtrees
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Queue persistence (architecture/queue-persistence.md) flattens the queue
|
||||
into one folder of link files. The user now wants to **capture whole
|
||||
subtrees**: pressing `w` on a queueable item in the *library* (an artist,
|
||||
an album, a playlist folder) snapshots it into a local tree that
|
||||
**preserves structure** — an artist becomes a folder of album folders,
|
||||
each holding the playable track files. The store is one more local fs
|
||||
provider ("bookmarks"); replaying is plain fs-provider behavior. On top,
|
||||
the created top-level folders must be **renamable and deletable** from
|
||||
the TUI — and the same should hold for saved queues.
|
||||
|
||||
## Assumptions (confirmed against the code)
|
||||
|
||||
- `fsdy::Client` is instance-mountable since queue persistence
|
||||
(`Client::new(provider_root, disk_root)`); a third instance is cheap.
|
||||
- The TUI already routes `e`/`d` through `is_editable`/`is_deletable`
|
||||
flags on `LibraryNodeChild` into the existing
|
||||
`RenameLibraryNode`/`DeleteLibraryNode` rpcs, and the orchestrator
|
||||
already routes those to the `/queues` (and future `/bookmarks`)
|
||||
instances. Making folders modifiable therefore needs **zero TUI
|
||||
changes** — only fsdy must set flags and implement rename/delete.
|
||||
- The capture walk can reuse `get_lib_node` through the
|
||||
`ProviderOrchestrator` (any provider reachable), and
|
||||
`TrackFile::from_track` + `track_file_name` from queue persistence for
|
||||
the leaves.
|
||||
- `w` is unbound in the TUI's `Library` scope; the input overlay handles
|
||||
ask-for-a-name flows and supports prefilling (rename does).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Third fsdy instance `/bookmarks`; capture is a server-side walk
|
||||
|
||||
A `BookmarkStore` (sibling of `QueueStore`) owns
|
||||
`<config>/crabidy/bookmarks/`; the orchestrator mounts a read-only fsdy
|
||||
instance over it at `/bookmarks` and is the only writer. Capture runs on
|
||||
the **orchestrator side** (it must call `get_lib_node` across providers):
|
||||
a new `ProviderCommand::CaptureLibraryNode { path, name }` is handled on
|
||||
a spawned task (like `ResolveTracks` — a big artist walk must not block
|
||||
the loop). The reply arrives when the write finished.
|
||||
|
||||
Not chosen: capturing client-side in the TUI (would duplicate provider
|
||||
access) or reusing the playback loop (captures are not queue state).
|
||||
|
||||
### D2 — Structure fidelity: order-prefixed folders and files
|
||||
|
||||
The walk mirrors the subtree iteratively (worklist, pre-order):
|
||||
|
||||
- Each child **node** becomes a folder named `NNNN <title>` (same
|
||||
zero-padded prefix and sanitizer as queue entries, no suffix) — the
|
||||
case-insensitive listing sort then reproduces the provider's child
|
||||
order, which is meaningful (album track order, discography order).
|
||||
- Each **track** becomes `NNNN <title>.cbd-track.toml` via the existing
|
||||
`TrackFile::from_track` (uniform link playable) — metadata is captured
|
||||
at save time; drift is accepted like everywhere else.
|
||||
- A node carrying both tracks and children (search terms) writes both.
|
||||
- Capturing a *track* selection is allowed: a folder with one link file.
|
||||
- Whole-bookmark writes are tmp-and-swap like queues; an existing
|
||||
bookmark of the same name is overwritten.
|
||||
|
||||
**Safety caps**: the walk aborts (typed error, temp dir removed) beyond
|
||||
1 000 directories or 20 000 tracks — a runaway provider tree must not
|
||||
fill the disk. Cycles are impossible under the cap (it bounds total
|
||||
nodes, not depth). Since captured listings rewrite link tracks to their
|
||||
targets, capturing a bookmark re-links to the *original* targets — no
|
||||
link chains ever get written.
|
||||
|
||||
### D3 — New rpc `CaptureLibraryNode(path, name)`
|
||||
|
||||
Additive proto change (the only wire change). Name validation is shared
|
||||
with queue saving (trimmed, no separators/NUL, no leading dot; no
|
||||
reserved names in `/bookmarks`). Mapping: invalid name/source →
|
||||
`invalid_argument`, capture disabled (no config dir) →
|
||||
`failed_precondition`, walk/write failures → `internal`. The response is
|
||||
empty — the TUI stays where it is (unlike `%`-create, capturing is not a
|
||||
navigation; the bookmark appears under `/bookmarks` on the next visit).
|
||||
|
||||
### D4 — Mutable top-level folders as an fsdy instance option
|
||||
|
||||
`fsdy::Client` gains a builder option
|
||||
`with_editable_top_level(reserved_names)`:
|
||||
|
||||
- The instance-root listing marks child *folders* `is_editable` and
|
||||
`is_deletable`, except reserved names.
|
||||
- `rename_lib_node`: only direct children of the instance root; new
|
||||
title validated like a store name; renaming onto an existing sibling
|
||||
is `InvalidInput` (folders never merge); returns the renamed node (the
|
||||
TUI navigates into it, as with search terms).
|
||||
- `delete_lib_node`: only direct children of the root; `remove_dir_all`;
|
||||
idempotent (already gone → success); returns the refreshed root
|
||||
listing.
|
||||
- Deeper levels stay immutable — the user request covers the *created*
|
||||
folders; restructuring inside a capture is file-manager work.
|
||||
|
||||
Applied to `/bookmarks` (no reserved names) **and `/queues`** (reserved:
|
||||
`current`, which auto-persist owns — it can be neither renamed nor
|
||||
deleted, and nothing can be renamed onto it). `/fs` keeps the immutable
|
||||
default. Not chosen: implementing rename/delete in the stores — the
|
||||
providers already own path→disk mapping and the rpc routing exists.
|
||||
|
||||
### D5 — TUI: `w` in the library scope
|
||||
|
||||
`Action::LibraryCaptureNode` bound to `w` in `Scope::Library` ("Save
|
||||
selection as bookmark"): opens the input overlay **prefilled with the
|
||||
selected item's title**, gated on the bare selection being queueable
|
||||
(marks are ignored — one capture per invocation). Submit sends
|
||||
`MessageFromUi::CaptureNode { path, name }` → the new rpc. Rename (`e`)
|
||||
and delete (`d`) of bookmark/queue folders ride the existing flows via
|
||||
the D4 flags.
|
||||
|
||||
### D6 — Out of scope (explicitly)
|
||||
|
||||
- Capturing multiple marked items at once; capture progress display.
|
||||
- Rename/delete below the top level; moving bookmarks between folders.
|
||||
- Refreshing a bookmark from its source (re-capture under the same name
|
||||
overwrites — that *is* the refresh).
|
||||
- A creatable `/bookmarks` root (`%`) — bookmarks are created from the
|
||||
source tree.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
server: crabidy-server {
|
||||
orch: ProviderOrchestrator {
|
||||
cap: "capture walk (spawned):\nget_lib_node -> mirror tree"
|
||||
}
|
||||
bstore: BookmarkStore {
|
||||
w: "validate name, caps,\ntmp-and-swap"
|
||||
}
|
||||
}
|
||||
|
||||
tidal: tidaldy
|
||||
fs: "fsdy /fs"
|
||||
qfs: "fsdy /queues\n(editable top level,\nreserved: current)"
|
||||
bfs: "fsdy /bookmarks\n(editable top level)"
|
||||
|
||||
disk: "config/crabidy/bookmarks" {
|
||||
shape: cylinder
|
||||
tree: "<name>/NNNN <album>/NNNN <track>.cbd-track.toml"
|
||||
}
|
||||
|
||||
server.orch.cap -> tidal: "walk source subtree"
|
||||
server.orch.cap -> server.bstore: "write mirrored tree"
|
||||
server.bstore -> disk
|
||||
bfs -> disk: "list + parse (read only)"
|
||||
server.orch -> bfs: "/bookmarks/... (browse, queue, e/d)"
|
||||
server.orch -> qfs: "e/d on saved queues"
|
||||
```
|
||||
|
||||
## Key flow: capture an artist, rename it, replay an album
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
tui: TUI
|
||||
orch: Orchestrator
|
||||
tidal: tidaldy
|
||||
store: BookmarkStore
|
||||
|
||||
tui -> orch: "CaptureLibraryNode(/tidal/artists/42, faves)"
|
||||
orch -> tidal: "get_lib_node (artist, albums, ...)"
|
||||
orch -> store: "write faves/0001 Album/0001 Song.cbd-track.toml ..."
|
||||
store -> tui: OK
|
||||
tui -> orch: "RenameLibraryNode(/bookmarks/faves, road faves)"
|
||||
orch -> tui: "renamed node (TUI navigates in)"
|
||||
tui -> orch: "ReplaceQueue([/bookmarks/road%20faves/0001%20Album])"
|
||||
orch -> tui: "resolve walk streams the album's tracks"
|
||||
```
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Capture duration**: a large artist means many provider fetches; the
|
||||
TUI's poll loop awaits the rpc like other slow calls (accepted,
|
||||
consistent with search-term creation). The orchestrator loop itself
|
||||
stays free (spawned task).
|
||||
- **Rename/delete racing a re-capture** of the same name: last writer
|
||||
wins on the swap; accepted for a single-user local server.
|
||||
- **Prefix width** (9999 entries per folder) shared with queues;
|
||||
accepted.
|
||||
- Open (future): re-capture/refresh command; capturing marked sets;
|
||||
editable nesting.
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# Capture deletion
|
||||
|
||||
Deleting under `/captures` reclaims disk: downloaded audio is the one
|
||||
library content that is expensive to recreate (slow, throttled downloads
|
||||
— architecture/youtube-rustypipe.md), so stale captures must be
|
||||
removable from the TUI, and removal must actually delete the files.
|
||||
|
||||
## Context
|
||||
|
||||
Before this feature, deletion (`d`) was limited to *top-level* folders
|
||||
of editable fsdy instances (architecture/bookmarks.md D4): whole
|
||||
captures could be deleted (and were removed from disk via
|
||||
`remove_dir_all`), but nothing below — no single album, no single
|
||||
track. Deletes were deliberately unconfirmed because every deletable
|
||||
node was cheap to recreate.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — deletable tree as an fsdy instance option
|
||||
|
||||
`fsdy::Client::with_deletable_tree()` makes every folder below the
|
||||
instance root deletable (recursively, any depth) and every track file
|
||||
deletable. Only the `/captures` instance sets it:
|
||||
|
||||
- `/queues` and `/bookmarks` keep the top-level-only contract; their
|
||||
nested structure mirrors a snapshot and partial edits are better done
|
||||
by re-saving.
|
||||
- Nested folders become deletable but **not renamable** — renames would
|
||||
break the incremental-capture merge by name
|
||||
(architecture/incremental-captures.md), deletes cannot: a re-capture
|
||||
of the same name simply re-downloads what is missing.
|
||||
- The instance root itself and reserved top-level names stay
|
||||
undeletable even on a deletable tree.
|
||||
|
||||
### D2 — tracks advertise deletability through their node
|
||||
|
||||
Wire truth, not client guessing: the TUI must not hardcode which tracks
|
||||
are deletable. But a per-`Track` flag would touch every Track literal
|
||||
in every provider for a capability only fsdy uses. Instead
|
||||
`LibraryNode.tracks_deletable` says "this node's listed tracks may be
|
||||
deleted", exactly like the existing `is_downloadable` inheritance
|
||||
("tracks inherit their node's blessing", architecture/captures.md D4).
|
||||
Child folders keep using the existing per-child `is_deletable`.
|
||||
|
||||
### D3 — a deleted track takes its audio with it, inside the root only
|
||||
|
||||
Deleting a track file removes the `.cbd-track.toml` **and** the audio
|
||||
its `[playable] file` points to — that is the point of the feature.
|
||||
Safety boundary: the audio path (relative values resolved against the
|
||||
track file's directory) is canonicalized and must live inside the
|
||||
canonicalized instance root; anything else is kept and logged. So a
|
||||
hand-written track file referencing `~/Music/song.flac` from inside the
|
||||
captures folder can never delete foreign data, and `..`/symlink tricks
|
||||
resolve before the check. A track file that no longer parses is deleted
|
||||
blind (its audio cannot be located; the listing skipped it anyway).
|
||||
Deletes stay idempotent per the proto contract.
|
||||
|
||||
### D4 — confirmation in the client, scoped to /captures
|
||||
|
||||
`d` on anything under `/captures` opens a one-line modal prompt
|
||||
(`delete <title>? [y/N]`, red) instead of sending; only `y`/`Y`
|
||||
confirms, any other key cancels. Every other deletable (search terms,
|
||||
bookmarks, saved queues) stays a single unconfirmed keypress
|
||||
(architecture/node-editing.md D4) — they are cheap to recreate, and a
|
||||
blanket confirmation would train reflexive `y`. The scoping is a path
|
||||
check in the TUI (like the `/captures` never-cache rule in
|
||||
`cbd-tui/src/rpc.rs`): the server does not know which deletes a client
|
||||
should consider expensive.
|
||||
|
||||
## Flow
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
tui: cbd-tui {
|
||||
d: "d on /captures/…"
|
||||
confirm: "delete …? [y/N]"
|
||||
d -> confirm
|
||||
}
|
||||
server: crabidy-server {
|
||||
provider_loop: provider loop
|
||||
}
|
||||
fsdy: fsdy /captures instance {
|
||||
folder: "folder: remove_dir_all"
|
||||
track: "track: toml + contained audio"
|
||||
}
|
||||
tui.confirm -> server.provider_loop: y → DeleteLibraryNode
|
||||
server.provider_loop -> fsdy.folder
|
||||
server.provider_loop -> fsdy.track
|
||||
fsdy.folder -> tui: refreshed parent listing
|
||||
```
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- The confirmation prompt occupies the same line as the text-input
|
||||
overlay; both are strictly modal and never open together.
|
||||
- Deleting the folder of a *running* capture is possible; the capture
|
||||
walk recreates directories as it goes and re-downloads on the next
|
||||
run, so the race wastes bandwidth but corrupts nothing.
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
# Captures (downloaded subtrees)
|
||||
|
||||
> **Partially superseded** by `incremental-captures.md`: download captures
|
||||
> are now incremental (no tmp-and-swap, re-capturing a name resumes it),
|
||||
> uncapturable tracks are recorded as *skipped* tomls instead of being
|
||||
> omitted, and the capture RPC streams progress. Bookmarks keep the
|
||||
> tmp-and-swap described here.
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Bookmarks (`w`) mirror a library subtree as **link** files — replaying a
|
||||
bookmark still needs the original provider. The user wants `W` on a library
|
||||
node to do the same capture into a separate local provider called
|
||||
**captures**, except each track's audio is **downloaded** next to its
|
||||
`.cbd-track.toml`, and the toml points at that file. Playback of a capture
|
||||
then needs no provider round trip at all — it is a fully local copy.
|
||||
|
||||
A library node decides whether it allows `W`; Tidal implements it.
|
||||
|
||||
## Assumptions (confirmed against the code)
|
||||
|
||||
- `PlayableSpec.file` already supports **relative** paths, resolved against
|
||||
the track file's directory at `get_urls_for_track` time (fs-provider D3).
|
||||
A toml next to its audio file can say `file = "0001 Song.flac"` and the
|
||||
whole capture folder stays relocatable (tmp-and-swap, rename, backup).
|
||||
- `fsdy::list_dir` only surfaces directories and `*.cbd-track.toml` files;
|
||||
downloaded audio siblings are invisible to the library listing.
|
||||
- The bookmark walk (`bookmark_store::write_capture`) is an iterative
|
||||
pre-order worklist whose only per-track action is "serialize and write
|
||||
one file" — exactly the seam where a download variant plugs in.
|
||||
- `reqwest` (rustls, `stream`) is already a workspace dependency; tidal
|
||||
stream URLs come from `get_urls_for_track` on the orchestrator, so the
|
||||
download needs no new provider methods.
|
||||
- Uppercase bindings (`K`, `J`) already exist in the TUI bindings table;
|
||||
`W` in `Scope::Library` is free.
|
||||
- `LibraryNode`/`LibraryNodeChild` already model per-node capabilities
|
||||
(`is_queable`, `is_creatable`, …) — the "does this node allow `W`"
|
||||
decision extends that pattern.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Fourth `fsdy` instance at `/captures`
|
||||
|
||||
`<config>/crabidy/captures/` is mounted read-only as `/captures` with an
|
||||
editable top level (no reserved names), exactly like `/bookmarks`. Init is
|
||||
non-fatal: an unopenable store disables `W` and the mount, never the
|
||||
server. Loading a capture is browsing `/captures` and queueing a folder —
|
||||
zero new replay mechanisms.
|
||||
|
||||
### D2 — One shared walk, two track sinks
|
||||
|
||||
Options considered:
|
||||
|
||||
- *(a)* Copy `bookmark_store.rs` and swap the per-track write.
|
||||
- *(b)* Extract the walk into a shared `capture` module parameterized by a
|
||||
**track sink**; bookmarks and captures become thin stores over it.
|
||||
|
||||
**Decision: (b)** — the walk (worklist, caps, temp-and-swap, all-or-nothing
|
||||
cleanup, `BadSource` mapping) is behavior we already tested once and must
|
||||
not fork. `crabidy-server/src/capture.rs` owns `CaptureError`, the caps,
|
||||
and `write_tree(client, source, tmp, caps, sink)`; the sink is an enum
|
||||
(no async-trait indirection):
|
||||
|
||||
- `Sink::Link` — today's bookmark behavior, byte-identical
|
||||
(`TrackFile::from_track`, link playable).
|
||||
- `Sink::Download(Downloader)` — captures (D3).
|
||||
|
||||
`BookmarkStore` keeps its API; `CaptureStore` (in `capture_store.rs`) is
|
||||
its sibling over the captures directory.
|
||||
|
||||
### D3 — Download sink: audio next to the toml, toml points at it
|
||||
|
||||
Per track, in listing order:
|
||||
|
||||
1. `get_urls_for_track` through the orchestrator (any provider that yields
|
||||
URLs works; Tidal is the target). First URL wins.
|
||||
2. HTTP GET via one shared `reqwest` client — connect timeout, one total
|
||||
per-track deadline covering the whole body, **no retries** (a capture
|
||||
is re-runnable and overwrite = refresh; a retry policy can come later).
|
||||
The body is streamed to `NNNN <title>.<ext>` (shared `ordered_name`
|
||||
sanitizer, same 4-digit prefix as the toml so the pair sorts together).
|
||||
3. The extension comes from the response `Content-Type`
|
||||
(`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`,
|
||||
`audio/mpeg` → `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`),
|
||||
falling back to the URL path's extension, then `bin` (the player probes
|
||||
by content; the extension is a hint).
|
||||
4. The toml is written **after** the download succeeds, with
|
||||
`playable.file = "<audio file name>"` (relative, new
|
||||
`TrackFile::from_track_with_file`), keeping metadata identical to a
|
||||
bookmark entry.
|
||||
|
||||
Caps: `MAX_CAPTURE_DIRS` stays 1 000; downloads get their own
|
||||
`MAX_DOWNLOAD_TRACKS = 500` and a total byte budget
|
||||
`MAX_DOWNLOAD_BYTES = 4 GiB` counted while streaming — a runaway artist
|
||||
capture must not fill the disk. Downloads run sequentially (gentle on the
|
||||
provider, trivially bounded memory); the whole capture already runs on a
|
||||
spawned task, so the orchestrator keeps serving.
|
||||
|
||||
All-or-nothing is kept for real failures: any failed download (bad
|
||||
status, transport error, timeout) aborts the capture and removes the temp
|
||||
folder. The one softening: a track whose source **cannot be captured at
|
||||
all** — its stream fails to resolve, or resolves to a non-http(s) target
|
||||
(a local file playable) — is *skipped* with a warning instead of
|
||||
aborting. Queue and bookmark captures mix providers (D4), and one local
|
||||
`/fs` entry must not kill the downloadable rest; the skipped track simply
|
||||
has no pair in the capture.
|
||||
|
||||
### D4 — Nodes opt in via `is_downloadable`
|
||||
|
||||
New proto fields `LibraryNode.is_downloadable = 8` and
|
||||
`LibraryNodeChild.is_downloadable = 7` (additive). Tidal sets the flag
|
||||
centrally at the end of `get_lib_node`: a node is downloadable when it is
|
||||
**queueable or lists tracks** (the "or lists tracks" covers search-term
|
||||
result nodes, which are not queueable as a whole but whose track results
|
||||
are downloadable); children mirror `is_queable`. The `/queues` and
|
||||
`/bookmarks` instances opt in wholesale
|
||||
(`fsdy::Client::with_downloadable_nodes`): their entries are links into
|
||||
downloadable providers, so `W` on a saved queue or bookmark downloads
|
||||
its resolvable tracks and skips the rest (D3). `/fs` and `/captures`
|
||||
stay `false` — capturing a capture is pointless, and local trees have
|
||||
nothing to download.
|
||||
|
||||
Tracks carry no flag: a listed track inherits its containing node's
|
||||
`is_downloadable` (TUI) — a Tidal album's tracks are downloadable because
|
||||
the album is. The server enforces at the capture **root**: a directory
|
||||
source must report `is_downloadable`, a track source's parent node must
|
||||
(`CaptureError::Unsupported` otherwise). Nested nodes inside the walk are
|
||||
not re-checked — the pressed node's decision governs its subtree.
|
||||
|
||||
### D5 — Wire: the existing rpc gains a `download` flag
|
||||
|
||||
`CaptureLibraryNodeRequest` gets `bool download = 3` (additive; old
|
||||
clients keep bookmarking). `ProviderCommand::CaptureLibraryNode` carries
|
||||
it and the handler picks the store. Error mapping extends the bookmark
|
||||
contract: `InvalidName`/`BadSource` → `invalid_argument`,
|
||||
`TooLarge`/`Disabled`/`Unsupported` → `failed_precondition`, download and
|
||||
disk failures → `internal`.
|
||||
|
||||
### D6 — TUI: `W` on the library pane
|
||||
|
||||
`Action::LibraryDownloadNode` bound to `W` in `Scope::Library` ("Download
|
||||
selection as capture"). Gate: the bare selection must be queueable **and**
|
||||
downloadable (`selected_downloadable()`; child flag for nodes, the current
|
||||
node's flag for tracks; marks ignored like `w`). The existing input
|
||||
overlay opens with `InputPurpose::Capture { path, download: true }`
|
||||
(label `capture`), prefilled with the selection title. Submit sends
|
||||
`MessageFromUi::CaptureNode { path, name, download }` → the rpc. Failures
|
||||
are logged, never fatal to the poll loop.
|
||||
|
||||
### D7 — Out of scope (explicitly)
|
||||
|
||||
- Retry/resume of failed or partial downloads (re-run the capture).
|
||||
- Quality/codec selection, transcoding, tagging the audio files.
|
||||
- Progress display in the TUI while a capture downloads.
|
||||
- Deduplicating audio across captures, or refreshing links in existing
|
||||
bookmarks into downloads.
|
||||
- DRM circumvention: the download uses exactly the stream URLs the
|
||||
provider already serves for playback.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
server: crabidy-server {
|
||||
orch: ProviderOrchestrator
|
||||
cap: "capture.rs\nshared walk + caps + swap" {
|
||||
link: "Sink::Link"
|
||||
dl: "Sink::Download\n(reqwest, timeouts, byte budget)"
|
||||
}
|
||||
bs: BookmarkStore
|
||||
cs: CaptureStore
|
||||
}
|
||||
|
||||
tidal: "tidaldy\n(is_downloadable = is_queable)"
|
||||
|
||||
disk: "config/crabidy" {
|
||||
shape: cylinder
|
||||
b: "bookmarks/<name>/ (link tomls)"
|
||||
c: "captures/<name>/ (audio + file tomls)"
|
||||
}
|
||||
|
||||
cfs: "fsdy /captures\n(editable top level)"
|
||||
|
||||
server.orch -> server.bs: "CaptureLibraryNode\ndownload=false"
|
||||
server.orch -> server.cs: "CaptureLibraryNode\ndownload=true"
|
||||
server.bs -> server.cap.link
|
||||
server.cs -> server.cap.dl
|
||||
server.cap.dl -> tidal: "get_urls_for_track\n+ HTTP GET stream"
|
||||
server.bs -> disk.b
|
||||
server.cs -> disk.c
|
||||
cfs -> disk.c: "list + parse (read only)"
|
||||
server.orch -> cfs: "/captures/..."
|
||||
```
|
||||
|
||||
## Key flow: W on a Tidal album
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
tui: TUI
|
||||
rpc: gRPC
|
||||
orch: Orchestrator
|
||||
cs: CaptureStore
|
||||
tidal: Tidal
|
||||
|
||||
tui -> rpc: "CaptureLibraryNode(path, name, download=true)"
|
||||
rpc -> orch: "ProviderCommand (spawned)"
|
||||
orch -> cs: "capture(name)"
|
||||
cs -> orch: "get_lib_node: root allows download?"
|
||||
cs -> tidal: "per track: get_urls_for_track"
|
||||
cs -> tidal: "HTTP GET (deadline, byte budget)"
|
||||
cs -> cs: "audio + toml pair\n(toml after audio, file = relative)"
|
||||
cs -> rpc: "tmp-and-swap captures/<name>/"
|
||||
rpc -> tui: OK
|
||||
```
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Disk usage**: 500 tracks of FLAC can be tens of GiB; the byte budget
|
||||
caps one capture, not the folder's total. Accepted — the user manages
|
||||
`captures/` like any local music folder (and can delete via `d`).
|
||||
- **Stream URL churn**: Tidal URLs are short-lived; the download happens
|
||||
immediately after fetching each URL, so expiry only matters for very
|
||||
slow transfers, which the per-track deadline already bounds.
|
||||
- **Licensing**: captures are personal-use copies of streams the account
|
||||
can already play; nothing here bypasses provider protection.
|
||||
- Open (future): a progress event stream for long captures; retry with
|
||||
classification + jitter; per-provider download quality knobs.
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# cbd: bundled server + client binary
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
`crabidy-server` and `cbd-tui` are separate binaries: the normal setup
|
||||
runs a long-lived server and attaches TUIs to it. The user wants a
|
||||
single binary **`cbd`** for the one-machine case: starting it starts the
|
||||
server and connects the TUI to it. Everything else works exactly the
|
||||
same — same config *format*, same gRPC wire, same features. (`cbd` reads
|
||||
its own `cbd.toml`, not `cbd-tui.toml`; see the resolved note under
|
||||
Risks.)
|
||||
|
||||
## Assumptions (confirmed)
|
||||
|
||||
- `cbd-tui`'s config (`cbd-tui.toml`) already carries the server
|
||||
address; the server listens on a constant (`0.0.0.0:50051`).
|
||||
- Both mains are thin shells over module code: the server's `main.rs`
|
||||
holds the command/message enums and the startup sequence; the TUI's
|
||||
holds tracing setup and two loops (`orchestrate`, `run_ui`).
|
||||
- The gRPC boundary stays: the bundled TUI talks to the in-process
|
||||
server over localhost exactly like a remote one ("works the same
|
||||
completely"). No in-process transport special-casing.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Both binaries become libraries with thin mains
|
||||
|
||||
- **crabidy-server**: `playback`, `provider`, `rpc` move from bin
|
||||
modules to lib modules; the command/message enums and the startup
|
||||
sequence move into the lib (`serve(addr)` builds orchestrator, queue
|
||||
store, playback, rpc service and serves tonic on `addr`). `main.rs`
|
||||
keeps only stderr tracing setup + `serve(LISTEN_ADDR)`.
|
||||
- **cbd-tui**: gains `src/lib.rs` exposing `run(config)` (the two loops
|
||||
and their channels); `main.rs` keeps file-based tracing setup +
|
||||
config init + `run`.
|
||||
- Behavior-preserving: no logic changes, only module moves and
|
||||
`crabidy_server::` → `crate::` path rewrites. All existing tests move
|
||||
along unchanged.
|
||||
|
||||
Not chosen: `cbd` spawning `crabidy-server` as a subprocess — that
|
||||
needs the second binary installed, which is exactly what "a single
|
||||
binary" is for.
|
||||
|
||||
### D2 — `cbd` = start (or adopt) the server, then run the TUI
|
||||
|
||||
New tiny binary crate `cbd`:
|
||||
|
||||
1. Tracing goes to the TUI's log file for **both** halves — the
|
||||
terminal belongs to the TUI, so the server's stderr logging would
|
||||
corrupt it.
|
||||
2. Spawn `crabidy_server::serve(LISTEN_ADDR)` on a background task.
|
||||
If the port is already taken (`AddrInUse` — a standalone server is
|
||||
running), log and carry on: the TUI simply connects to the existing
|
||||
server. Any other server error before readiness is fatal.
|
||||
3. Wait for readiness by polling a TCP connect against the configured
|
||||
server address (bounded retries with delay, then a clear error).
|
||||
4. Run the TUI exactly as `cbd-tui` would, with the same
|
||||
`cbd-tui.toml`.
|
||||
|
||||
Quitting the TUI ends the process — and with it the in-process server.
|
||||
That is inherent to bundling and fine: the current queue is persisted
|
||||
continuously, so the next start restores it (the ≤200 ms persist
|
||||
debounce window is the same loss window as killing the standalone
|
||||
server).
|
||||
|
||||
### D3 — Out of scope (explicitly)
|
||||
|
||||
- An in-process (channel) transport instead of localhost gRPC.
|
||||
- Daemonizing: `cbd` never outlives its TUI. Users who want a
|
||||
persistent server keep running `crabidy-server`.
|
||||
- CLI subcommands (`cbd server`, `cbd attach`, …) — later if wanted.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
cbd: "cbd (one binary)" {
|
||||
boot: "main: file tracing,\nspawn server, wait, run TUI"
|
||||
srv: "crabidy-server lib\nserve(addr)"
|
||||
tui: "cbd-tui lib\nrun(config)"
|
||||
boot -> srv: "tokio::spawn\n(AddrInUse → adopt)"
|
||||
boot -> tui: "after TCP readiness"
|
||||
tui -> srv: "localhost gRPC\n(unchanged wire)"
|
||||
}
|
||||
|
||||
standalone: "crabidy-server bin\n(unchanged)"
|
||||
remote: "cbd-tui bin\n(unchanged)"
|
||||
remote -> standalone: "gRPC (remote setup\nkeeps working)"
|
||||
```
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Separate client configs (resolved 2026-07-21)**: originally `cbd`
|
||||
and `cbd-tui` both read `cbd-tui.toml`, so pointing that file at a
|
||||
remote server (the standalone `cbd-tui`'s job) also dragged `cbd`'s
|
||||
own TUI to the remote while it started an unused local server. `cbd`
|
||||
now reads its own `cbd.toml` (same option set, defaulting to
|
||||
localhost — matching its in-process server), so the self-contained
|
||||
`cbd` and a remote-pointed `cbd-tui` coexist on one machine. See
|
||||
`architecture/client-configs.md`.
|
||||
- **Two log producers, one file**: server and TUI layers share the
|
||||
bundled tracing subscriber; targets distinguish them.
|
||||
- Open (future): a `--no-server` flag; graceful server shutdown (flush
|
||||
the persister) on TUI exit.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Client configs: `cbd.toml` vs `cbd-tui.toml`
|
||||
|
||||
## Problem
|
||||
|
||||
`cbd` (server + TUI in one process) and `cbd-tui` (standalone terminal
|
||||
client) both originally loaded `cbd-tui.toml`. On a single machine the
|
||||
common setup is:
|
||||
|
||||
- `cbd` — self-contained, playing on this laptop against its own
|
||||
in-process server (localhost);
|
||||
- `cbd-tui` — a remote control pointed at another server (e.g. a
|
||||
Raspberry Pi).
|
||||
|
||||
With one shared config file these two uses fight over a single
|
||||
`address`: point it at the Pi for `cbd-tui`, and `cbd`'s TUI half also
|
||||
connects to the Pi while its local server runs unused.
|
||||
|
||||
## Decision
|
||||
|
||||
Give each binary its own client config file, with the **same option
|
||||
set** (`address`, `user`, `password` — the `ServerConfig` type is
|
||||
shared):
|
||||
|
||||
- `cbd` reads `cbd.toml`.
|
||||
- `cbd-tui` reads `cbd-tui.toml`.
|
||||
|
||||
Both default `address` to `http://127.0.0.1:50051`. For `cbd` that
|
||||
default is the right permanent value — it matches the in-process
|
||||
server's listen address — so `cbd` needs no config at all. `cbd-tui`'s
|
||||
default is a starting point the user overrides to point at a remote
|
||||
server. The files being separate is the whole point: editing one never
|
||||
moves the other.
|
||||
|
||||
The server-side `crabidy-server.toml` (the `[auth]` roles) is unrelated
|
||||
and unchanged; this split is purely about the client `address`/creds.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **One file, add a `cbd`-only override section**: still one file to
|
||||
reason about, and the override semantics (which wins?) are murkier
|
||||
than two files with identical shape. Rejected.
|
||||
- **A CLI flag only for `cbd`**: does not persist; the user wants a
|
||||
laptop that "just works" on every start. Rejected (the flag still
|
||||
exists as an override, as for `cbd-tui`).
|
||||
|
||||
## Migration
|
||||
|
||||
Existing users keep their `cbd-tui.toml` for `cbd-tui`. On first run
|
||||
after the change, `cbd` writes a fresh `cbd.toml` with localhost
|
||||
defaults; anyone who had customized `cbd-tui.toml` *for `cbd`'s* sake
|
||||
(unusual — the default already fit) copies the value across once.
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
# Filesystem provider
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
crabidy currently has exactly one media provider (Tidal, crate `tidaldy`)
|
||||
behind the `ProviderClient` trait and the `ProviderOrchestrator` that routes
|
||||
by path prefix. The user wants a second provider that walks a local
|
||||
directory tree and treats files with a well-known extension as *serialized
|
||||
track nodes*: small metadata files that describe a track and point at the
|
||||
thing that actually plays. The playable reference can be
|
||||
|
||||
1. a **local audio file** (mp3/flac/… somewhere on disk),
|
||||
2. a **web URL** (a stream, a radio station, a direct http(s) link), or
|
||||
3. a **crabidy-internal link** (a track path owned by another provider,
|
||||
e.g. `/tidal/artists/3634161/536243361`).
|
||||
|
||||
The request's open question — "new datastructure or our existing node?" —
|
||||
is decided below (D1: existing node on the wire, a new on-disk schema for
|
||||
the file).
|
||||
|
||||
## Assumptions (confirmed against the code)
|
||||
|
||||
- `audio-player` already plays both cases we need natively
|
||||
(`player_engine.rs`): a source string that parses as an `http(s)` URL is
|
||||
streamed via `stream-download`; anything else is opened as a **local
|
||||
file path**. No player changes are required. (`file://` URLs would be
|
||||
rejected — the provider must return plain paths, not file URLs.)
|
||||
- `Track.path` is the routing key for playback: the queue stores whole
|
||||
`Track` messages, and `GetTrackUrls`/`get_metadata_for_track` route by
|
||||
the path's first segment in `ProviderOrchestrator`. Nothing in the
|
||||
server assumes a track's path belongs to the provider whose node listed
|
||||
it.
|
||||
- The default `ProviderClient::resolve_tracks_into` walk (one chunk per
|
||||
track-bearing node, pre-order) is fast enough for local disk I/O; the
|
||||
page-streaming override exists for slow remote APIs.
|
||||
- The TUI needs **no changes**: `/fs` appears as one more child of the
|
||||
synthetic root, directories are nodes, track files are tracks.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Reuse `Track`/`LibraryNode`; the only new schema is on disk
|
||||
|
||||
Options considered:
|
||||
|
||||
- *(a)* New proto message (e.g. `TrackRef` with a `oneof playable`) carried
|
||||
through queue, RPCs, and TUI.
|
||||
- *(b)* Reuse the existing `Track`/`LibraryNode` messages unchanged; the
|
||||
"reference to a playable thing" lives only inside the fs provider's
|
||||
on-disk file and is resolved to ordinary crabidy semantics at the
|
||||
provider boundary.
|
||||
|
||||
**Decision: (b).** A new wire type would ripple through the queue, every
|
||||
RPC, and both clients for zero client-visible benefit — the queue and TUI
|
||||
only ever need *metadata + a playable path*, which `Track` already is. The
|
||||
new datastructure is purely the **serialized track-file schema** (D3),
|
||||
private to the fs provider crate.
|
||||
|
||||
### D2 — Internal links resolve by *path rewriting* at listing time
|
||||
|
||||
Options considered:
|
||||
|
||||
- *(a)* Keep `Track.path = /fs/...` for link tracks and add an indirection
|
||||
mechanism at play time (orchestrator re-dispatches `get_urls_for_track`
|
||||
when the fs provider reports a redirect).
|
||||
- *(b)* When the fs provider builds a `Track` from a link file, it sets
|
||||
`Track.path` to the **link target** (e.g. `/tidal/...`). The file's own
|
||||
metadata still fills artist/title/album. From then on the track *is* a
|
||||
tidal track as far as the queue and playback are concerned; the
|
||||
orchestrator's existing prefix routing does the rest.
|
||||
|
||||
**Decision: (b).** Zero new mechanisms: `get_urls_for_track` and metadata
|
||||
refresh route to the owning provider automatically, and a dead target
|
||||
degrades exactly like any other dead tidal track (playback warn + skip).
|
||||
Consequences, accepted deliberately:
|
||||
|
||||
- The queue shows the metadata written in the file (authoritative by the
|
||||
user's own description), not the target's live metadata.
|
||||
- `get_urls_for_track` on an fs path whose playable is a link cannot occur
|
||||
through normal flow (the path was rewritten before it could be queued);
|
||||
if it happens anyway it is `MalformedPath` with a warning, not a chain
|
||||
resolution. **Links therefore resolve one hop by construction**: a link
|
||||
whose target is itself a link file dies at play time with a warning, and
|
||||
cycles cannot recurse. (Amended by queue-persistence D2: the original
|
||||
"no links into `/fs`" parse-time rejection was dropped — persisted
|
||||
queues must link to `/fs` tracks — and this one-hop argument replaces
|
||||
it.)
|
||||
|
||||
### D3 — On-disk schema: TOML, extension `.cbd-track.toml`, exactly one playable
|
||||
|
||||
TOML per project convention. A file named `<anything>.cbd-track.toml` inside
|
||||
the configured root is a track node; everything else (other files, hidden
|
||||
entries) is ignored. Schema:
|
||||
|
||||
```toml
|
||||
# Required.
|
||||
title = "We Will Rock You"
|
||||
# Optional; empty when omitted (web radio streams often have no artist).
|
||||
artist = "Queen"
|
||||
# Optional, seconds.
|
||||
duration = 122
|
||||
|
||||
# Optional.
|
||||
[album]
|
||||
title = "News of the World"
|
||||
release_date = "1977-10-28"
|
||||
|
||||
# Required: exactly one of `file`, `url`, `link`.
|
||||
[playable]
|
||||
file = "../flac/we-will-rock-you.flac"
|
||||
# url = "https://example.org/stream.mp3"
|
||||
# link = "/tidal/artists/3634161/536243361"
|
||||
```
|
||||
|
||||
- `playable` is parsed as a struct of three `Option`s and validated to
|
||||
**exactly one** set — this gives precise error messages, unlike an
|
||||
untagged serde enum.
|
||||
- `file`: absolute, or relative to the *track file's directory* (so a
|
||||
music folder stays relocatable). Existence is **not** checked at listing
|
||||
time (TOCTOU; the player produces a good error at play time).
|
||||
- `url`: must parse as `http`/`https` (matching what the player accepts).
|
||||
- `link`: must be an absolute crabidy path (`/`-prefixed). Links into fs
|
||||
instances (including `/fs` itself) are legal — persisted queues rely on
|
||||
it (queue-persistence D2); safety comes from links resolving one hop
|
||||
only (see D2 above).
|
||||
- A file that fails to parse or validate is **skipped with a warning** at
|
||||
listing time; it never panics and never poisons its directory (hard
|
||||
rule: no panic on user input).
|
||||
|
||||
### D4 — Library mapping: one configured root, encoded segments, sorted listing
|
||||
|
||||
- Config `~/.config/crabidy/fsdy.toml`, written back with defaults on
|
||||
first run like `tidaly.toml`. Single field `root` (absolute path);
|
||||
default `dirs::audio_dir()` (`~/Music`). One root keeps the path scheme
|
||||
flat; multiple roots stay future work (they would need a
|
||||
`/fs/<root-name>/` layer).
|
||||
- Paths: `/fs/<seg>/<seg>/…` where each segment is
|
||||
`encode_segment(file_name)` — the same escaping search terms use, so
|
||||
arbitrary file names (spaces, `%`, unicode) survive the path scheme.
|
||||
- **Traversal safety**: decoded segments are rejected if they are `.`/`..`
|
||||
or contain a path separator; the joined path is a pure descent from the
|
||||
root by construction.
|
||||
- **Symlinks are skipped** during directory listing (`file_type()` without
|
||||
follow) — no cycles, no escaping the root. A `playable.file` target may
|
||||
be a symlink; that is the player's problem.
|
||||
- Listing order: directories and track files each sorted
|
||||
case-insensitively by file name — deterministic queueing order; users
|
||||
order albums with `01`-style file name prefixes as everywhere else.
|
||||
- Directories are `LibraryNodeChild { is_queable: true }`; queueing one
|
||||
resolves its whole subtree via the **default** `resolve_tracks_into`
|
||||
walk (one chunk per directory — local disk needs no page streaming).
|
||||
Empty directories are fine: they contribute nothing.
|
||||
- Fresh read on every navigation, no cache, no file watching — edits with
|
||||
a file manager appear on the next visit.
|
||||
|
||||
### D5 — New crate `fsdy`, non-fatal init, orchestrator routing
|
||||
|
||||
- New workspace crate **`fsdy`** (naming symmetry with `tidaldy`),
|
||||
`PROVIDER_ROOT = "/fs"`, implementing `ProviderClient`.
|
||||
- `ProviderOrchestrator` gains `fs_client: Option<Arc<fsdy::Client>>` and
|
||||
routes `/fs` prefixes in every trait method; `get_lib_root` adds the
|
||||
`/fs` child only when the client exists. **Init failure is non-fatal**
|
||||
(warn + run without `/fs`): unlike Tidal, a broken local config must not
|
||||
take the whole server down, and existing installations have no
|
||||
`fsdy.toml` yet. All I/O through `tokio::fs` (no blocking the runtime).
|
||||
|
||||
### D6 — Out of scope (explicitly)
|
||||
|
||||
- `create/rename/delete_lib_node`: `NotSupported`. Track files are edited
|
||||
with normal file tools; a TUI editor for them is future work.
|
||||
- Reading audio-file tags (ID3 etc.) to synthesize track nodes for plain
|
||||
`.mp3` files sitting in the tree: future work — this feature is about
|
||||
the serialized-node format.
|
||||
- Multiple roots, file watching: rejected above. Link chains resolve at
|
||||
most one hop (D2); deeper chains fail at play time by design.
|
||||
- Since queue-persistence D1, `fsdy::Client::new(provider_root, disk_root)`
|
||||
can mount additional instances (the server mounts `/queues` over the
|
||||
persisted-queues folder); `fsdy.toml` still configures only `/fs`.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
disk: Local disk {
|
||||
shape: cylinder
|
||||
tree: "root dir: dirs, *.cbd-track.toml"
|
||||
}
|
||||
|
||||
server: crabidy-server {
|
||||
playback: Playback loop
|
||||
orch: ProviderOrchestrator {
|
||||
route: "route by first path segment"
|
||||
}
|
||||
}
|
||||
|
||||
fsdy: fsdy::Client {
|
||||
parse: "parse + validate .cbd-track.toml"
|
||||
map: "path <-> root-relative file (encoded segments)"
|
||||
}
|
||||
|
||||
tidaldy: tidaldy::Client
|
||||
|
||||
player: audio-player {
|
||||
url: "http(s) -> stream-download"
|
||||
file: "other -> File::open"
|
||||
}
|
||||
|
||||
server.playback -> server.orch: "GetTrackUrls(track.path)"
|
||||
server.orch -> fsdy: "/fs/..."
|
||||
server.orch -> tidaldy: "/tidal/..."
|
||||
fsdy -> disk.tree: tokio::fs
|
||||
server.playback -> player: "play(url | file path)"
|
||||
```
|
||||
|
||||
## Key flow: queue a directory containing all three playable kinds
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
tui: TUI
|
||||
pb: Playback loop
|
||||
orch: Orchestrator
|
||||
fs: fsdy
|
||||
tidal: tidaldy
|
||||
|
||||
tui -> pb: "ReplaceQueue([/fs/mix])"
|
||||
pb -> orch: ResolveTracks("/fs/mix", chunk_tx)
|
||||
orch -> fs: resolve_tracks_into (spawned)
|
||||
fs -> fs: "list dir, parse 3 track files"
|
||||
fs -> pb: "chunk of 3 Tracks (paths below)" {style.bold: true}
|
||||
pb -> orch: "GetTrackUrls(/fs/mix/a.cbd-track.toml)"
|
||||
orch -> fs: get_urls_for_track
|
||||
fs -> pb: "[/home/u/Music/a.flac]"
|
||||
pb -> orch: "GetTrackUrls(/tidal/...) # link track, rewritten path"
|
||||
orch -> tidal: get_urls_for_track
|
||||
tidal -> pb: "[https://tidal-cdn/...]"
|
||||
```
|
||||
|
||||
(The second track's `Track.path` stays `/fs/...` — its playable is a URL,
|
||||
returned by `fsdy::get_urls_for_track`. Only `link` files rewrite the
|
||||
path.)
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Malicious/odd trees**: deep nesting is bounded only by the walk's
|
||||
worklist (memory-cheap); huge directories list in one node — accepted
|
||||
for local disk. Traversal and symlink escapes are closed by D4.
|
||||
- **Dangling references**: dead `file`/`url`/`link` targets surface at
|
||||
play time as the existing "failed to open / no provider owns" warnings;
|
||||
the queue keeps going. No preflight validation by design.
|
||||
- **Metadata drift** on link tracks (file says X, target now titled Y):
|
||||
accepted; the file is the user's curated metadata.
|
||||
- Open (future): tag-reading for bare audio files; multiple roots; a
|
||||
`%`-style creator that writes a `.cbd-track.toml` from inside the TUI.
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# Help modal for cbd-tui
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
`cbd-tui` is the ratatui/crossterm terminal client for crabidy. All keyboard
|
||||
handling lives in a single `match (app.focus, key.modifiers, key.code)` in
|
||||
`main.rs` (`run_ui`), covering global bindings plus per-pane bindings for the
|
||||
two focusable panes (`UiFocus::Library`, `UiFocus::Queue`). None of this is
|
||||
discoverable from inside the app: a new user has to read the source to learn
|
||||
that `Tab` cycles panes or that `a` appends the selected library node to the
|
||||
queue.
|
||||
|
||||
Goal: pressing `?` opens a help modal that explains basic usage (panes, focus
|
||||
cycling) and lists all keyboard shortcuts; a key press closes it again.
|
||||
|
||||
## Assumptions (confirmed)
|
||||
|
||||
- The help content must not be able to drift from the real bindings — the
|
||||
binding table becomes the single source of truth for both dispatch and help
|
||||
rendering (confirmed with user; see Options).
|
||||
- The modal is read-only and modal in the strict sense: while it is open, all
|
||||
other bindings are inert. `?`, `Esc`, and `q` close it (`q` therefore does
|
||||
**not** quit the app while help is open).
|
||||
- Bindings stay hardcoded for now. User-configurable keymaps are out of scope,
|
||||
but the table design must not preclude them later.
|
||||
- The modal shows **all** scopes (Global, Library, Queue) grouped, not just the
|
||||
bindings of the currently focused pane — the point is discovery.
|
||||
- No new dependencies; ratatui's `Clear` widget plus a centered `Rect` is
|
||||
enough for the overlay.
|
||||
|
||||
## Options considered
|
||||
|
||||
### Option A — static help text, dispatch untouched
|
||||
|
||||
A display-only `const HELP: &[(&str, &str, &str)]` table rendered by the
|
||||
modal; the existing `match` in `main.rs` stays as-is.
|
||||
|
||||
- **Pros**: smallest diff; zero refactor risk.
|
||||
- **Cons**: two parallel encodings of the same facts; every binding change now
|
||||
has a silently skippable second edit site. Historically this is exactly the
|
||||
kind of table that rots.
|
||||
|
||||
### Option B — declarative binding table (chosen)
|
||||
|
||||
Introduce `app/bindings.rs`:
|
||||
|
||||
- `Scope` — `Global | Library | Queue`, mirroring `UiFocus` plus a global tier.
|
||||
- `Action` — one variant per user-visible operation (`Quit`, `TogglePlay`,
|
||||
`VolumeUp`, `LibraryDown`, `QueueRemoveTrack`, …).
|
||||
- `Binding { scope, mods, code, action, description }` with
|
||||
`const BINDINGS: &[Binding]`.
|
||||
- `lookup(focus: UiFocus, key: KeyEvent) -> Option<Action>` — scope-aware
|
||||
table scan (global entries match in any focus; pane entries only when that
|
||||
pane is focused).
|
||||
- A `key_label(mods, code) -> String` formatter so the help modal derives the
|
||||
displayed key from the same data dispatch uses (no hand-written "Ctrl+d"
|
||||
strings).
|
||||
|
||||
The event loop shrinks to: translate `KeyEvent` → `Action` via `lookup`, then
|
||||
one `match action` executes it (`App::dispatch`). The help modal renders
|
||||
`BINDINGS` grouped by `Scope`.
|
||||
|
||||
- **Pros**: single source of truth; help cannot drift; the loop's 30-arm match
|
||||
becomes data; natural seam for configurable keymaps later.
|
||||
- **Cons**: moderate refactor of `run_ui`; `Action` execution needs access to
|
||||
both `&mut App` and the `Sender<MessageFromUi>` (solved by giving `App` its
|
||||
own `tx`, which it already receives in `App::new`).
|
||||
|
||||
**Decision: Option B**, confirmed with the user.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
main: main.rs run_ui loop {
|
||||
poll: crossterm event poll
|
||||
}
|
||||
|
||||
app: app module {
|
||||
bindings: bindings.rs {
|
||||
table: "BINDINGS: &[Binding]"
|
||||
lookup: "lookup(focus, key) -> Option<Action>"
|
||||
label: "key_label(mods, code)"
|
||||
}
|
||||
state: App {
|
||||
focus: "focus: UiFocus"
|
||||
help: "show_help: bool"
|
||||
dispatch: "dispatch(action)"
|
||||
}
|
||||
help_modal: help.rs {
|
||||
render: "render_help(frame)"
|
||||
}
|
||||
}
|
||||
|
||||
server: crabidy-server (gRPC)
|
||||
|
||||
main.poll -> app.bindings.lookup: KeyEvent
|
||||
app.bindings.lookup -> app.state.dispatch: Action
|
||||
app.bindings.table -> app.bindings.lookup: dispatch reads
|
||||
app.bindings.table -> app.help_modal.render: help reads same table
|
||||
app.state.dispatch -> server: MessageFromUi via tx
|
||||
app.state.help -> app.help_modal.render: gates overlay
|
||||
```
|
||||
|
||||
## Key-press flow
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
|
||||
user: User
|
||||
loop: run_ui loop
|
||||
bindings: bindings::lookup
|
||||
app: App
|
||||
|
||||
user -> loop: presses "?"
|
||||
loop -> bindings: lookup(focus, key)
|
||||
bindings -> loop: "Some(Action::ToggleHelp)"
|
||||
loop -> app: "dispatch(ToggleHelp)"
|
||||
app -> app: "show_help = true"
|
||||
loop -> app: render()
|
||||
app -> app: draw panes, then help overlay (Clear + centered popup)
|
||||
|
||||
user -> loop: presses any bound key while help open
|
||||
loop -> bindings: lookup sees help-open state
|
||||
bindings -> loop: "only Close actions match (?, Esc, q)"
|
||||
```
|
||||
|
||||
## Boundaries and interfaces
|
||||
|
||||
- **`app/bindings.rs`** owns the vocabulary: `Scope`, `Action`, `Binding`,
|
||||
`BINDINGS`, `lookup`, `key_label`. Pure data + pure functions; no I/O, no
|
||||
ratatui types — unit-testable without a terminal.
|
||||
- **`App`** gains `show_help: bool`, a stored `tx: Sender<MessageFromUi>`, and
|
||||
`dispatch(&mut self, action: Action)`. `run_ui` keeps ownership of the loop
|
||||
and terminal; quitting stays a loop-level concern (`dispatch` returns a
|
||||
signal or `Action::Quit` is handled in the loop — decided in api-design).
|
||||
- **`app/help.rs`** renders the overlay: short usage paragraph (panes, `Tab`
|
||||
to switch focus) followed by the binding table grouped by scope. Reads
|
||||
`BINDINGS` only.
|
||||
- Modal gating lives in one place: when `show_help` is true, `lookup` (or the
|
||||
loop) only admits close actions. No other component needs to know the modal
|
||||
exists.
|
||||
|
||||
## Risks
|
||||
|
||||
- **`?` and modifier reporting**: terminals differ on whether `?` arrives with
|
||||
`SHIFT` set. Match `KeyCode::Char('?')` regardless of the shift modifier
|
||||
(as the existing `J`/`K`/`G` arms already do for shifted letters).
|
||||
- **Small terminals**: the full binding list may not fit. Initial version
|
||||
clamps the popup to the frame and truncates; scrolling is an explicit
|
||||
non-goal for now (open question below).
|
||||
- **Refactor regressions**: converting ~30 match arms to table entries risks
|
||||
transposition mistakes. Mitigated by unit tests asserting `lookup` results
|
||||
for every current binding (quality-gates stage).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the help modal scroll when the terminal is too small, or is
|
||||
truncation with a "…" indicator acceptable? (Default: truncate.)
|
||||
- Mouse support is enabled (`EnableMouseCapture`) but unused; clicking outside
|
||||
the modal to close it is a possible later nicety, not in scope.
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
# Incremental captures, skipped tracks, and capture progress
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Download captures (`W`, architecture/captures.md) are all-or-nothing: the
|
||||
whole subtree is built in a hidden temp folder and swapped into place; any
|
||||
failure destroys everything downloaded so far. For a large node that means
|
||||
hours of downloading can evaporate on one bad track, and re-running restarts
|
||||
from zero. Mixed-provider sources (queues, bookmarks) silently *omit*
|
||||
uncapturable tracks, so the capture's track list quietly diverges from the
|
||||
source. And while a capture runs, the user sees nothing — worse, the TUI's
|
||||
poll loop awaits the capture RPC, so the client is effectively frozen until
|
||||
the capture finishes.
|
||||
|
||||
This design makes download captures **incremental and resumable**, records
|
||||
uncapturable tracks as a first-class **skipped** playable, streams **capture
|
||||
progress** to clients, and warns about long captures up front. A small,
|
||||
unrelated fix rides along: colored library items (editable/creatable/marked)
|
||||
are unreadable under the focused selection bar (D7).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "Capture" here means the *download* capture (`W`). Bookmark captures (`w`)
|
||||
stay atomic tmp-and-swap: they are cheap, and "overwrite = refresh" is the
|
||||
right semantic for links. They do adopt the skipped playable for skipped
|
||||
source tracks (D1) and report progress (D5).
|
||||
- Resuming keys on the **name**: capturing into an existing capture name
|
||||
merges into that folder. Entry identity is the deterministic toml file name
|
||||
(`NNNN <title>.cbd-track.toml`), so resuming assumes the source keeps its
|
||||
order — appending to a queue is fine, reordering it re-captures under new
|
||||
names and leaves stale files behind (the user can delete the capture and
|
||||
start over). Accepted.
|
||||
- One capture per name at a time is the user's responsibility (same as the
|
||||
old racing-tmp behavior); concurrent same-name captures interleave per
|
||||
file, last writer wins. Accepted.
|
||||
|
||||
## D1 — A `skipped` playable
|
||||
|
||||
Track files get a fourth playable: `[playable] skipped = true`, validated
|
||||
with the same exactly-one cardinality as `file`/`url`/`link`
|
||||
(`skipped = false` counts as unset and is rejected). Semantics: *this
|
||||
position in the tree is a real track whose audio could not be captured*.
|
||||
|
||||
- `fsdy::Playable::Skipped`; `TrackFile::from_track_skipped(track)` builds
|
||||
one from a wire track.
|
||||
- Wire: `Track.is_skipped` (proto field 6). `TrackFile::to_track` sets it;
|
||||
the track's `path` stays the lib path (like `file`), there is nothing to
|
||||
route to.
|
||||
- `TrackFile::from_track` (queue persistence, bookmarks) writes a skipped
|
||||
playable when the source track `is_skipped` — skipped-ness survives queue
|
||||
persistence and bookmark round trips instead of degrading into a dead
|
||||
link.
|
||||
- `get_urls_for_track` on a skipped file returns `ProviderError::FetchError`
|
||||
(playback never asks, see D4; direct callers get a normal typed error).
|
||||
|
||||
Alternative considered: model skipped-ness as *absence* (keep omitting the
|
||||
track) plus a client-side diff against the source. Rejected — the source may
|
||||
be gone tomorrow; the capture itself must record the gap.
|
||||
|
||||
## D2 — Incremental download captures
|
||||
|
||||
`capture_into` splits into two phases:
|
||||
|
||||
1. **Enumerate**: the existing iterative pre-order walk collects every
|
||||
directory and track (with its listing index) first, enforcing
|
||||
`max_dirs`/`max_tracks`. This makes the total known before the first
|
||||
download — progress can be a real ratio — and costs only metadata calls.
|
||||
2. **Fetch**: process the collected tracks in order, feeding progress after
|
||||
each one.
|
||||
|
||||
The sink decides the write mode:
|
||||
|
||||
- `Sink::Link` (bookmarks): unchanged tmp-and-swap into `.tmp-<name>`,
|
||||
all-or-nothing.
|
||||
- `Sink::Download` (captures): writes **directly** into `dir/<name>/`,
|
||||
creating directories as needed, never deleting existing content. Per
|
||||
track, in order:
|
||||
- The target toml exists, parses, and its playable is *not* skipped, and
|
||||
(for a `file` playable) the referenced audio exists → **reuse** (counts
|
||||
as done, no download). A `url`/`link` playable also counts as satisfied
|
||||
— only this store writes here, but hand-edited files should not be
|
||||
clobbered.
|
||||
- Otherwise the track is (re)captured. A source that resolves to a
|
||||
local **file** path rather than an http(s) URL — an fs playable, or a
|
||||
track from an existing capture — is **copied** into the capture next
|
||||
to its toml (counting against the same byte budget as a download), so
|
||||
a queue mixing streamed and local tracks captures fully. A source
|
||||
that genuinely cannot be captured — the track is itself skipped, its
|
||||
stream fails to resolve, resolves to nothing, or the local file is
|
||||
missing/unreadable — writes a **skipped toml** (`from_track_skipped`)
|
||||
and counts as skipped. This replaces the old silent omission (and the
|
||||
older behavior of skipping local files outright).
|
||||
- A real download failure (HTTP status, transport, timeout, byte budget)
|
||||
**aborts the run but keeps everything written so far** — re-running the
|
||||
same name resumes exactly where it stopped, re-attempting skipped and
|
||||
missing entries only.
|
||||
|
||||
Audio is still written before its toml, so a crash mid-download leaves a
|
||||
toml-less audio file that the resume simply re-downloads (truncating on
|
||||
create). The byte budget counts only bytes downloaded *this run*, so resuming
|
||||
a large capture is never starved by what is already on disk.
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
walk: capture_into {
|
||||
enumerate: "phase 1: enumerate\n(dirs + tracks, caps)"
|
||||
fetch: "phase 2: fetch\n(per track, in order)"
|
||||
enumerate -> fetch: "total known"
|
||||
}
|
||||
walk.fetch -> reuse: "toml ok + audio present"
|
||||
walk.fetch -> skipped: "source uncapturable\n→ skipped = true toml"
|
||||
walk.fetch -> download: "download + toml"
|
||||
walk.fetch -> abort: "download failure\n(keeps progress)"
|
||||
```
|
||||
|
||||
## D3 — Skipped tracks in the queue
|
||||
|
||||
Skipped tracks queue like any other (the user sees the gap instead of a
|
||||
silently shorter queue). The TUI renders them red; playback skips them.
|
||||
|
||||
`Playback::play` already loops past tracks whose URLs fail to resolve. It now
|
||||
additionally:
|
||||
|
||||
- skips `is_skipped` tracks **without a provider round trip**, and
|
||||
- bounds the whole skip loop by the queue length at entry — an all-skipped
|
||||
queue with repeat on used to be an infinite provider-hammering spin; now
|
||||
it stops the player with a warning after one full pass.
|
||||
|
||||
## D4 — Capture progress on the update stream
|
||||
|
||||
New stream update (proto):
|
||||
|
||||
```proto
|
||||
message CaptureProgress {
|
||||
string name = 1; // capture / bookmark name
|
||||
bool download = 2; // W capture vs w bookmark
|
||||
uint32 tracks_done = 3; // settled: reused + downloaded + linked +
|
||||
// skipped — reaches tracks_total on success
|
||||
uint32 tracks_total = 4; // known after enumeration (0 until then)
|
||||
uint32 tracks_skipped = 5; // of those, skipped tomls written this run
|
||||
bool finished = 6;
|
||||
string error = 7; // set iff finished with a failure
|
||||
}
|
||||
```
|
||||
|
||||
`CaptureLibraryNode` now returns once the capture is **accepted**: the
|
||||
provider validates the name, the store, and the source's download blessing,
|
||||
replies, and runs the walk on its spawned task, streaming `CaptureProgress`
|
||||
through a bounded channel that the RPC layer forwards into the existing
|
||||
update broadcast. Completion and failure arrive as the final progress event
|
||||
(`finished`, `error`), not as the RPC result.
|
||||
|
||||
Rationale: the TUI's orchestration loop `select!`s over one RPC at a time —
|
||||
a capture RPC that lasts an hour freezes every other interaction. Validation
|
||||
errors still come back synchronously with the old status mapping; walk
|
||||
errors move to the stream (and the server log, as before).
|
||||
|
||||
## D5 — TUI: progress, red skipped tracks, warnings
|
||||
|
||||
- **Skipped tracks are red** (and not bold) in both the queue and library
|
||||
listings, driven by `Track.is_skipped`. The playing-track red keeps
|
||||
precedence in the queue.
|
||||
- **Progress lines** render at the bottom of the library pane, one per
|
||||
active capture: `capturing <name> 12/34 (2 skipped)` (bookmarks:
|
||||
`bookmarking`). A finished capture lingers ~5 s as
|
||||
`captured <name>: 34 tracks (2 skipped)`; a failed one shows the error in
|
||||
red for ~10 s. State lives in the `App`, fed by the update stream; the
|
||||
100 ms render tick handles expiry.
|
||||
- **Warnings**: the `W` help-table description and the capture input
|
||||
overlay's label both say a download capture can take a long time (and that
|
||||
re-capturing the same name resumes it).
|
||||
|
||||
## D6 — Out of scope
|
||||
|
||||
- Cancelling a running capture from the TUI.
|
||||
- Retrying real download failures within a run (rerun-to-resume covers it).
|
||||
- Garbage-collecting stale entries when the source shrank or reordered.
|
||||
- Multi-hop link resolution for skipped detection (a link whose target is a
|
||||
skipped file plays as a normal link failure).
|
||||
|
||||
## D7 — Focused-selection contrast fix
|
||||
|
||||
Library items styled with a foreground color (creatable/editable/deletable →
|
||||
secondary, marked → green; queue: skipped/current → red) are hard to read
|
||||
when the focused selection bar (`bg = COLOR_PRIMARY`, a light blue) sits on
|
||||
them. Fix: when an item is the selected row of a *focused* pane, its
|
||||
foreground switches to the dark `COLOR_PRIMARY_DARK` so it reads against the
|
||||
light bar. The unfocused bar is dark and keeps the colored foregrounds.
|
||||
|
||||
## Risks
|
||||
|
||||
- Enumerate-then-fetch holds the full entry list in memory: bounded by
|
||||
`max_tracks` (500 download / 20 000 bookmark) — fine.
|
||||
- A source whose listing order changes between runs duplicates content under
|
||||
new prefixes (assumption above). Accepted; documented in the help text via
|
||||
the "resumes by name" phrasing.
|
||||
- The progress channel is bounded (64); a slow broadcast consumer only slows
|
||||
the walk, never blocks it permanently (the forwarder drains continuously).
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# Editable and deletable library nodes
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
The search feature (`architecture/search.md`) introduced *creatable* nodes:
|
||||
`%` under `/tidal/search` turns a typed term into a tree node holding search
|
||||
results. Those nodes are currently immutable — a typo'd term can only be
|
||||
abandoned, and stale terms accumulate for the lifetime of the server process.
|
||||
|
||||
This feature makes such nodes *modifiable*: in the library pane, `e` renames
|
||||
the selected node (for a search term: re-runs the search under the new term)
|
||||
and `d` deletes it. Modifiability is a per-node capability advertised by the
|
||||
provider, exactly like `is_creatable` — the TUI never hardcodes which paths
|
||||
support what.
|
||||
|
||||
Run autonomously per standing user instruction; every decision below records
|
||||
the options considered and the rationale.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "Nodes that got created via `%`" are today exactly the search-term nodes
|
||||
under `/tidal/search`; the design must not special-case them (playlists are
|
||||
the obvious future candidate), but they are the only provider implementation
|
||||
in this iteration.
|
||||
- Editing means **renaming** (the title is the only user-supplied property a
|
||||
node has). For a search term, the title *is* the query, so a rename re-runs
|
||||
the search.
|
||||
- `e`/`d` act on the **selected item in the library list** (the same selection
|
||||
model as queueing), not on the currently-open node.
|
||||
- Proto changes must stay wire-compatible (additive fields/rpcs only), as with
|
||||
the search feature.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Two capability flags, on the child only
|
||||
|
||||
Options considered:
|
||||
|
||||
1. One `is_modifiable` flag implying both rename and delete.
|
||||
2. Two flags `is_editable` / `is_deletable`.
|
||||
3. Flags on both `LibraryNodeChild` and `LibraryNode` (mirroring
|
||||
`is_creatable`).
|
||||
|
||||
**Decision: (2), child-only** — `LibraryNodeChild.is_editable = 5`,
|
||||
`LibraryNodeChild.is_deletable = 6`. Future node kinds plausibly support only
|
||||
one of the two (a favorites entry may be deletable but not renamable), and an
|
||||
extra bool costs nothing on the wire. Unlike `is_creatable` (consumed for the
|
||||
*open* node: pane hint + `%` target), edit/delete are only ever checked
|
||||
against the selected **child**, so node-level copies would have no consumer —
|
||||
they are left out until something reads them.
|
||||
|
||||
### D2 — RPC shapes
|
||||
|
||||
Options considered:
|
||||
|
||||
1. One generic `UpdateLibraryNode` with optional fields + a separate delete.
|
||||
2. `RenameLibraryNode(path, new_title) → node` and
|
||||
`DeleteLibraryNode(path) → parent node`.
|
||||
|
||||
**Decision: (2).** Rename is the only edit that exists; a generic update
|
||||
message would be speculative surface area. Both responses carry the node the
|
||||
TUI should display next, following `CreateLibraryNode`:
|
||||
|
||||
- **Rename returns the renamed node** and the TUI navigates into it — the
|
||||
same "show me the result" behavior as `%` create. The path changes on
|
||||
rename (term is percent-encoded into the path), so returning the node is
|
||||
also what tells the client the new path.
|
||||
- **Delete returns the refreshed parent node** — the user is looking at the
|
||||
parent listing when they press `d`; returning it saves a follow-up
|
||||
`GetLibraryNode` and can never serve a stale cached listing.
|
||||
|
||||
### D3 — Provider semantics (tidaldy)
|
||||
|
||||
- `rename_lib_node(path, new_title)`: `path` must parse to
|
||||
`TidalPath::SearchTerm`, else `NotSupported`. Title is trimmed; empty →
|
||||
`InvalidInput`. The old term is **replaced in place** (keeps its position in
|
||||
the terms list). Renaming to an already-existing term merges: the old entry
|
||||
is removed, the existing one wins — the list never holds duplicates.
|
||||
Renaming a term the server doesn't know (stale client cache, restart)
|
||||
registers the new term — same forgiveness as `get_lib_node` on unknown
|
||||
terms. Returns `get_lib_node(new_path)`.
|
||||
- `delete_lib_node(path)`: `path` must parse to `TidalPath::SearchTerm`, else
|
||||
`NotSupported`. Removing an unknown term succeeds silently — delete is
|
||||
idempotent. Returns the refreshed parent node (`get_lib_node(/tidal/search)`).
|
||||
- Both reuse the `search_terms` `RwLock` discipline: poison-tolerant, never
|
||||
held across an await.
|
||||
- **Queued search tracks survive rename/delete**: a queue entry
|
||||
`/tidal/search/<enc-term>/<track-id>` resolves URLs/metadata from the
|
||||
embedded track id alone; term registration is irrelevant to playback.
|
||||
|
||||
### D4 — No delete confirmation (for now)
|
||||
|
||||
**Options**: confirm prompt (`y`/`n` mini-mode) vs immediate delete.
|
||||
|
||||
**Decision: immediate.** The only deletable nodes are search terms, which are
|
||||
free to recreate (`%` + retype); a confirmation mode adds a third input state
|
||||
for no protected value. **Open question**: when higher-value nodes (user
|
||||
playlists) become deletable, a confirmation step must be revisited — noted
|
||||
here so the decision is rediscovered.
|
||||
|
||||
### D5 — TUI input overlay grows a purpose
|
||||
|
||||
`InputState` today hardcodes creation (`parent_path` + buffer). It becomes
|
||||
|
||||
```rust
|
||||
InputState { purpose: InputPurpose, buffer: String }
|
||||
enum InputPurpose { Create { parent_path }, Rename { path } }
|
||||
```
|
||||
|
||||
- `e` opens the overlay **prefilled with the current title** (append-only
|
||||
editing as before: chars push, Backspace pops); Enter sends
|
||||
`MessageFromUi::RenameNode`, Esc cancels. Overlay label: `rename: <buffer>▏`
|
||||
vs `new node: <buffer>▏`.
|
||||
- Prefilling means "rename" degrades gracefully to "retype" — no cursor
|
||||
movement is introduced in this iteration (matches the existing overlay).
|
||||
- Submitting an unchanged title is sent anyway; the provider treats it as a
|
||||
no-op rename and returns the node (harmless refresh).
|
||||
- `d` sends `MessageFromUi::DeleteNode { path }` directly (D4).
|
||||
- Both keys are silently ignored when the selected item lacks the flag,
|
||||
mirroring `%` on non-creatable nodes.
|
||||
- List marker: editable/deletable children render a `[e]`, `[d]` or `[ed]`
|
||||
suffix in `COLOR_SECONDARY`, alongside the existing `[%]` for creatable
|
||||
ones. The bindings table gains `e`/`d` in the Library scope (plain `e` and
|
||||
`d` are unbound there today; `d` only exists in Queue scope), so the help
|
||||
modal picks them up automatically.
|
||||
|
||||
### D6 — Client cache handling
|
||||
|
||||
`RpcClient` keeps `library_node_cache`. On rename: evict the **old path** and
|
||||
the **parent**, insert the returned node under its new path. On delete: evict
|
||||
the deleted path and the parent, insert the returned parent. (Same reasoning
|
||||
as create's eviction — a stale `/tidal/search` listing would resurrect the
|
||||
old term in the UI.)
|
||||
|
||||
### D7 — Server plumbing
|
||||
|
||||
Two new `ProviderCommand`s (`RenameLibraryNode`, `DeleteLibraryNode`) with the
|
||||
established bounded(1) reply rendezvous; orchestrator routes `/tidal`-prefixed
|
||||
paths to the tidal client and answers anything else `NotSupported`. gRPC error
|
||||
mapping is identical to create: `NotSupported` → `failed_precondition`,
|
||||
`InvalidInput` → `invalid_argument`, rest → `internal`; no internals leak into
|
||||
`Status` messages.
|
||||
|
||||
## Flows
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
user: { shape: person }
|
||||
tui: cbd-tui
|
||||
server: crabidy-server
|
||||
tidal: tidaldy
|
||||
|
||||
user -> tui: "e on selected [ed] node"
|
||||
tui -> tui: open overlay prefilled with title
|
||||
user -> tui: edit text, Enter
|
||||
tui -> server: RenameLibraryNode(path, new_title)
|
||||
server -> tidal: rename_lib_node
|
||||
tidal -> tidal: replace term in list (merge on collision)
|
||||
tidal -> server: node at new path (fresh search)
|
||||
server -> tui: renamed node
|
||||
tui -> tui: evict old path + parent, show renamed node
|
||||
|
||||
user -> tui: "d on selected [ed] node"
|
||||
tui -> server: DeleteLibraryNode(path)
|
||||
server -> tidal: delete_lib_node
|
||||
tidal -> tidal: remove term (idempotent)
|
||||
tidal -> server: refreshed parent node
|
||||
server -> tui: parent node
|
||||
tui -> tui: evict path + parent, show parent listing
|
||||
```
|
||||
|
||||
Capability flags travel with every listing:
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
tidaldy: {
|
||||
search_arm: "get_lib_node(/tidal/search)"
|
||||
}
|
||||
proto: "LibraryNodeChild { is_editable=5, is_deletable=6 }"
|
||||
tui: {
|
||||
list: "library list: title [ed]"
|
||||
keys: "e -> rename overlay\nd -> DeleteNode"
|
||||
}
|
||||
tidaldy.search_arm -> proto: term children flagged
|
||||
proto -> tui.list: render marker
|
||||
proto -> tui.keys: gate actions
|
||||
```
|
||||
|
||||
## Boundaries and risks
|
||||
|
||||
- **Proto**: additive only — two rpcs, two child fields (5, 6). Old clients
|
||||
ignore the flags and never call the rpcs; old servers reject unknown rpcs
|
||||
with `unimplemented` (tonic default), which the TUI logs without crashing.
|
||||
- **Rename-to-collision** merges terms; the response node is the *existing*
|
||||
term's node. The user sees the results they asked for either way.
|
||||
- **Concurrent clients**: two TUIs editing the same term list race benignly —
|
||||
the list is a `RwLock`-guarded Vec, every operation is atomic under the
|
||||
write lock, and stale views self-heal on the next listing fetch.
|
||||
- **Not in scope**: editing anything but the title; deleting non-search
|
||||
nodes; confirmation UX (D4); persistent search terms (still per-process,
|
||||
as shipped by the search feature).
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
# Progressive queueing of large collections
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Queueing a nested node (an artist with many albums, a large playlist) today
|
||||
freezes the UI's mental model: nothing changes for many seconds, then the full
|
||||
queue appears at once. Three compounding causes, all in the resolve path:
|
||||
|
||||
1. `Playback::resolve_tracks` collects **every** track before the queue is
|
||||
touched, so the single `Queue` broadcast happens only at the very end and
|
||||
playback cannot start earlier.
|
||||
2. The resolve runs **inline in the playback loop**, so every other playback
|
||||
command — pause, next, volume — is blocked for the duration.
|
||||
3. `tidaldy` paginates collections to exhaustion (50 tracks per sequential
|
||||
request) inside one `get_lib_node` call, so even a single large playlist
|
||||
produces no intermediate result.
|
||||
|
||||
The feature: resolve progressively. Tracks are applied to the queue in chunks
|
||||
as the provider produces them, each chunk is broadcast, playback starts with
|
||||
the first chunk, and clients see a loading indicator (animated dots as a
|
||||
pseudo last queue item) while resolution is still running.
|
||||
|
||||
Run autonomously per standing user instruction; every decision below records
|
||||
the options considered and the rationale.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Chunks must arrive **in playback order** — the user explicitly wants "the
|
||||
first elements first, and later chunks follow". Order is known up front
|
||||
(collection order), so no reordering step is needed.
|
||||
- The four resolve-based queue operations (`Replace`, `Queue`, `Append`,
|
||||
`Insert`) all benefit equally and should share one mechanism.
|
||||
- Wire changes must stay additive (old clients keep working; they simply see
|
||||
the queue fill progressively without an indicator).
|
||||
- Multi-client remains supported: the indicator must be server-derived state,
|
||||
not client-local guessing.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — End-to-end progressive resolution, not a client-only spinner
|
||||
|
||||
Options considered:
|
||||
|
||||
1. **Client-only indicator**: the TUI shows dots between sending a queue op
|
||||
and receiving the next `Queue` update. No wire or server change.
|
||||
2. **Server-side chunked resolution** with a wire-visible "still resolving"
|
||||
flag; the indicator falls out of the flag.
|
||||
|
||||
**Decision: (2).** Option 1 papers over the latency without fixing it —
|
||||
playback would still start only after the full resolve, other clients would
|
||||
see nothing, and the "did it even register?" dead time remains. Option 2
|
||||
fixes the actual complaint (start playing early, fill visibly) and gives
|
||||
every client the indicator for free. The server broadcasts an immediate
|
||||
`Queue` update (unchanged tracks, `resolving = true`) when the op is
|
||||
accepted, so feedback appears within one round trip.
|
||||
|
||||
### D2 — `Queue.resolving` field, not a new stream-update variant
|
||||
|
||||
Options considered:
|
||||
|
||||
1. New `GetUpdateStream` oneof variant `resolving(bool)`.
|
||||
2. New field `bool resolving = 4` on the `Queue` message itself.
|
||||
|
||||
**Decision: (2).** The flag is queue state and must be atomic with the track
|
||||
snapshot it describes; a separate variant can arrive out of order relative to
|
||||
`Queue` updates (the broadcast channel is lossy for slow clients). Additive
|
||||
field, wire-compatible both ways: old clients ignore it, old servers never
|
||||
set it.
|
||||
|
||||
### D3 — Chunked resolution lives in `ProviderClient`, with a default
|
||||
|
||||
Options considered:
|
||||
|
||||
1. Keep the BFS in `ProviderOrchestrator` (chunk = one node's tracks); no
|
||||
trait change. A 1000-track playlist is still one 20-request blob.
|
||||
2. Add a chunked resolve method to the `ProviderClient` trait with a default
|
||||
implementation (the generic walk, one chunk per node); `tidaldy`
|
||||
overrides it to stream **page-sized chunks (50)** for the paginated
|
||||
collections (playlists, albums).
|
||||
|
||||
**Decision: (2).**
|
||||
|
||||
```rust
|
||||
/// Streams the playable tracks under `path` into `chunk_tx` in playback
|
||||
/// order. Zero or more chunks, then the sender is dropped: a dropped
|
||||
/// SENDER means resolution finished. A dropped RECEIVER cancels
|
||||
/// resolution (the provider stops fetching and returns Ok).
|
||||
async fn resolve_tracks_into(
|
||||
&self,
|
||||
path: &str,
|
||||
chunk_tx: flume::Sender<Vec<Track>>,
|
||||
) -> Result<(), ProviderError>;
|
||||
```
|
||||
|
||||
The channel semantics are the contract and are documented on the trait —
|
||||
this is a local bounded channel used as a stream, not a queue pretending to
|
||||
be durable. Unreadable nodes are skipped with a warning (today's behavior);
|
||||
only a completely unresolvable root returns an error. The default
|
||||
implementation walks the tree **depth-first pre-order** over queueable
|
||||
descendants, emitting one chunk per node. This replaces the old orchestrator
|
||||
BFS, and fixes a latent ordering bug while doing so: the old worklist popped
|
||||
LIFO, so an artist's albums were flattened in *reverse* order.
|
||||
|
||||
`tidaldy` overrides the method: playlist and album paths stream one chunk
|
||||
per fetched page instead of paginating to exhaustion first (a new
|
||||
`make_paginated_request` variant hands each page to a sink); everything else
|
||||
follows the generic walk. Search-term track lists are a single page already.
|
||||
The playlist arm also stops fetching the playlist metadata (title) — the
|
||||
resolve needs only tracks.
|
||||
|
||||
`flume` is already a workspace dependency; `crabidy-core` adopts it for the
|
||||
trait signature.
|
||||
|
||||
### D4 — Neither event loop blocks: spawned resolves, single-writer queue
|
||||
|
||||
Options considered:
|
||||
|
||||
1. Consume chunks inline in the playback loop's `handle_command` (loop still
|
||||
blocked for the whole resolve; pause/next dead — today's hidden defect).
|
||||
2. Spawn the resolve; queue mutations travel back to the playback loop as
|
||||
internal commands, so the loop stays the **single writer** of queue state.
|
||||
|
||||
**Decision: (2), on both loops.**
|
||||
|
||||
- **Provider side**: `ProviderOrchestrator::run` wraps the orchestrator in an
|
||||
`Arc`; the `ResolveTracks` arm spawns the resolve onto its own task instead
|
||||
of awaiting it inline. Without this, the first chunk would deadlock the
|
||||
system: playback applies chunk 1 → `play()` → sends `GetTrackUrls` and
|
||||
awaits the reply — but the provider loop would still be busy resolving.
|
||||
Other provider commands keep flowing while (possibly several) resolves run.
|
||||
- **Playback side**: each queue op registers a *pending op* (id from an
|
||||
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
|
||||
drives `ProviderCommand::ResolveTracks` per path (sequentially, preserving
|
||||
multi-path order) and forwards each chunk to the playback channel as
|
||||
`PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by
|
||||
`ResolveFinished { op_id }`. Queue state is only ever mutated inside the
|
||||
loop, exactly as before; user commands interleave between chunks.
|
||||
|
||||
Backpressure is real at every hop: provider → forwarder over a small bounded
|
||||
chunk channel, forwarder → playback over the existing `bounded(64)` command
|
||||
channel. A slow consumer slows the HTTP fetching down instead of buffering
|
||||
unboundedly.
|
||||
|
||||
### D5 — Chunk application semantics per op kind
|
||||
|
||||
Each pending op keeps an insertion cursor:
|
||||
|
||||
- **Replace**: first chunk `replace_with_tracks` (broadcast resets the
|
||||
queue, current position 0, playback starts); later chunks append.
|
||||
- **Append**: every chunk `append_tracks`.
|
||||
- **Queue** (after current): cursor starts at the current position; each
|
||||
chunk `insert_tracks(cursor)`, then `cursor += chunk.len()`.
|
||||
- **Insert**: same, starting at the requested position.
|
||||
|
||||
Playback start reuses the existing `Option<Track>` returns from the
|
||||
`QueueManager` mutations — only a chunk that makes a track current (replace,
|
||||
or any insert into an empty queue) yields one, so exactly the first relevant
|
||||
chunk starts the player and later chunks never restart it.
|
||||
|
||||
Interleaved edits from other clients during a resolve can shift the cursor's
|
||||
target (e.g. removing tracks before it). This is accepted as benign:
|
||||
`insert_tracks` already clamps, the queue self-heals on the next broadcast,
|
||||
and simultaneous multi-client edits during a resolve are rare. Under
|
||||
shuffle, arriving chunks are shuffled behind the current track like any
|
||||
other insert — chunk order is irrelevant when shuffle is on. Each op counts
|
||||
its applied tracks; an op that finishes with zero keeps today's
|
||||
"resolved to no playable tracks" warning.
|
||||
|
||||
### D6 — `Replace` and `Clear` cancel in-flight resolves
|
||||
|
||||
Without cancellation, "replace the queue" or "clear the queue" during a
|
||||
large resolve would be followed by the old op's remaining chunks trickling
|
||||
back in — a corrupted queue, and the exact ghost behavior this feature is
|
||||
meant to kill. Options: let stale chunks land (wrong), or cancel.
|
||||
|
||||
**Decision: cancel.** Each pending op carries an `Arc<AtomicBool>` shared
|
||||
with its forwarder. `Replace` and `Clear` mark every pending op cancelled
|
||||
and drop it from the map. The forwarder checks the flag per chunk and, when
|
||||
set, drops the chunk receiver — the provider's next `send` fails and the
|
||||
resolve task stops fetching (the documented receiver-drop semantics from
|
||||
D3). Chunks already in flight for an unknown op id are ignored by the loop.
|
||||
`Queue`/`Append`/`Insert` do **not** cancel: concurrent additive ops are
|
||||
legal; their chunks interleave between ops while each op's internal order is
|
||||
preserved.
|
||||
|
||||
### D7 — TUI indicator: an animated pseudo-item, outside the list model
|
||||
|
||||
While the latest `Queue` update carries `resolving = true`, the queue pane
|
||||
renders one extra line after the last track: one to three dots cycling
|
||||
(~400 ms per step, derived from elapsed time — the render loop already
|
||||
redraws at least every 100 ms), in `COLOR_SECONDARY`. The pseudo-item is
|
||||
appended at render time only and never enters `self.list`, so selection,
|
||||
removal, and `get_size` cannot reach it — no new input states, nothing to
|
||||
misclick.
|
||||
|
||||
## Flows
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
user: { shape: person }
|
||||
tui: cbd-tui
|
||||
rpc: gRPC handler
|
||||
playback: playback loop
|
||||
fwd: forwarder task
|
||||
provider: provider loop
|
||||
resolve: resolve task
|
||||
tidal: Tidal API
|
||||
|
||||
user -> tui: queue large artist
|
||||
tui -> rpc: Append(paths)
|
||||
rpc -> playback: "PlaybackCommand::Append (fire-and-forget)"
|
||||
playback -> playback: register pending op
|
||||
playback -> tui: "Queue update (resolving=true)"
|
||||
playback -> fwd: spawn
|
||||
fwd -> provider: "ResolveTracks(path, chunk_tx)"
|
||||
provider -> resolve: spawn
|
||||
resolve -> tidal: fetch page 1
|
||||
resolve -> fwd: chunk 1
|
||||
fwd -> playback: "ApplyResolvedChunk(op, chunk 1)"
|
||||
playback -> tui: "Queue update (resolving=true)"
|
||||
playback -> playback: "play() first track"
|
||||
resolve -> tidal: fetch page 2
|
||||
resolve -> fwd: chunk 2
|
||||
fwd -> playback: "ApplyResolvedChunk(op, chunk 2)"
|
||||
playback -> tui: "Queue update (resolving=true)"
|
||||
fwd -> playback: "ResolveFinished(op)"
|
||||
playback -> tui: "Queue update (resolving=false)"
|
||||
```
|
||||
|
||||
The dots pseudo-item is visible in the TUI exactly while updates carry
|
||||
`resolving = true`; user commands (pause, next, remove) flow through the
|
||||
playback loop between chunk applications instead of waiting for the end.
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
core: "ProviderClient::resolve_tracks_into" {
|
||||
default: "default: pre-order walk,\none chunk per node"
|
||||
}
|
||||
tidaldy: "tidaldy override" {
|
||||
pages: "playlist/album:\none chunk per 50-track page"
|
||||
}
|
||||
playback: "playback loop" {
|
||||
ops: "pending ops:\ncursor + cancel flag"
|
||||
}
|
||||
core -> tidaldy: overridden by
|
||||
tidaldy.pages -> playback.ops: "bounded chunks, in order"
|
||||
playback.ops -> playback.ops: "apply + broadcast per chunk"
|
||||
```
|
||||
|
||||
## Boundaries and risks
|
||||
|
||||
- **Proto**: one additive field (`Queue.resolving = 4`). No RPC shape
|
||||
changes; the queue ops stay fire-and-forget.
|
||||
- **Trait**: one new `ProviderClient` method with a default implementation —
|
||||
existing providers (there is one) compile unchanged if they skip the
|
||||
override; the override is where the provider-level win lives.
|
||||
- **Ordering fix is a behavior change**: multi-album artists now queue in
|
||||
listing order instead of reversed. Strictly a fix, noted here because
|
||||
someone may have gotten used to the bug.
|
||||
- **Concurrent additive ops interleave between ops.** Each op's internal
|
||||
order is kept; the interleaving matches command arrival order at the loop.
|
||||
Accepted — same semantics a human doing two appends "at once" expects.
|
||||
- **Old TUI + new server**: queue fills progressively, no indicator — pure
|
||||
improvement, no breakage. New TUI + old server: `resolving` is always
|
||||
false, indicator never shows, behavior as today.
|
||||
- **Not in scope**: pagination of `get_lib_node` for *browsing* (the library
|
||||
pane still fetches collections to exhaustion before rendering), queue
|
||||
persistence, a progress percentage (total counts are known per collection
|
||||
but not aggregated across a nested walk).
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
# Queue persistence
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
The queue lives only in the playback loop's memory: restarting
|
||||
`crabidy-server` loses it. The user wants
|
||||
|
||||
1. an **automatically maintained current queue**, persisted on every queue
|
||||
operation and reloaded when the server starts, and
|
||||
2. **named saved queues**: pressing `w` on the queue pane asks for a name
|
||||
and stores the current entries under it.
|
||||
|
||||
The explicit framing: realize this **completely with the fs provider** — a
|
||||
second `fsdy` instance pointed at a `queues/` folder inside the crabidy
|
||||
config directory, one subfolder per queue, each holding serialized track
|
||||
files.
|
||||
|
||||
## Assumptions (confirmed against the code)
|
||||
|
||||
- The proto already declares `SaveQueue(SaveQueueRequest{name})`; the
|
||||
server handler is a no-op stub (`rpc.rs`). No wire change is needed.
|
||||
- The playback loop is the single writer of queue state
|
||||
(`Playback.queue: Mutex<QueueManager>`); every content change funnels
|
||||
through `broadcast_queue`, every current-track change through `play`
|
||||
(plus the shuffle/repeat toggles). Hooking those sites observes every
|
||||
queue operation.
|
||||
- `fsdy` track files carry metadata plus one playable; a `link` playable
|
||||
rewrites `Track.path` to its target at listing time
|
||||
(architecture/fs-provider.md D2). Queueing a folder of link files
|
||||
therefore reconstructs the original tracks with zero new mechanisms.
|
||||
- `w` is unbound in the TUI's `Queue` scope; the input overlay
|
||||
(`InputState`/`InputPurpose`) already handles ask-for-a-name flows.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Mount a second `fsdy` instance at `/queues`
|
||||
|
||||
Options considered:
|
||||
|
||||
- *(a)* A new provider crate (`queuedy`) that owns the queues folder.
|
||||
- *(b)* Parameterize `fsdy::Client` with its provider root and mount a
|
||||
second instance at `/queues` over `<config>/crabidy/queues`.
|
||||
|
||||
**Decision: (b)** — the user's framing, and the listing/parsing/routing
|
||||
logic is byte-for-byte the same. `fsdy::Client` gains a constructor
|
||||
`Client::new(provider_root, disk_root)`; the `ProviderClient::init` path
|
||||
keeps building the `/fs` instance from `fsdy.toml`. The hardcoded
|
||||
`"/fs/"` prefixes in `disk_path`/`is_track_path`/`list_dir` become
|
||||
instance state. The orchestrator gains `queues_client` and `/queues`
|
||||
routing arms; init creates the folder (`create_dir_all`) and is non-fatal
|
||||
like `/fs` (a failure costs persistence, never the server). Loading a
|
||||
saved queue is just browsing `/queues` and queueing a folder — no new
|
||||
RPCs, no new TUI flows.
|
||||
|
||||
### D2 — Persist a queue as a folder of order-prefixed **link** files
|
||||
|
||||
Every queue entry becomes `NNNN <title>.cbd-track.toml` with the entry's
|
||||
metadata (title, artist, duration, album) and `playable.link =
|
||||
Track.path` — uniformly, for every entry. The 4-digit zero-padded prefix
|
||||
makes the case-insensitive listing sort reproduce queue order; the
|
||||
sanitized title keeps the files human-readable. Round trip: listing
|
||||
rewrites each link track's path back to its target, so reloading yields
|
||||
the original tracks with the persisted metadata.
|
||||
|
||||
**Consequence — the "no links into `/fs`" rule falls.** A queue may
|
||||
contain `/fs/...` tracks (file/url playables keep their fs path), so
|
||||
persisted files must be able to link into an fs-provider instance. The
|
||||
original rejection (fs-provider D3) existed to prevent chains; it is
|
||||
replaced by the stronger structural argument: **links are one hop by
|
||||
construction** — `get_urls_for_track` never follows a link (a
|
||||
link-playable target is `MalformedPath`), so a link whose target is
|
||||
itself a link file dies at play time with a warning, and cycles cannot
|
||||
recurse anywhere. `TrackFileError::LinkIntoFs` is removed; a link must
|
||||
merely be an absolute path. `architecture/fs-provider.md` D2/D3 are
|
||||
reconciled with this.
|
||||
|
||||
Not chosen: inlining the target's `file`/`url` playable into the saved
|
||||
file — the persister only has the wire `Track` (path + metadata), and
|
||||
links keep the saved queue pointing at the *node*, surviving edits to the
|
||||
underlying track file.
|
||||
|
||||
### D3 — Layout: `<config>/crabidy/queues/<name>/`, current queue = `current`
|
||||
|
||||
- The automatically maintained queue lives in `queues/current/` — a
|
||||
visible, ordinary queue folder (it shows up under `/queues` like any
|
||||
saved queue). The name is **reserved**: `SaveQueue("current")` is
|
||||
rejected so a named save is never silently clobbered by auto-persist.
|
||||
- Each queue folder carries a hidden sidecar `.queue-state.toml`
|
||||
(`current_position`, `repeat`, `shuffle`). Dot-prefixed → invisible to
|
||||
the provider listing. It is written for every queue and read only when
|
||||
restoring `current` at startup.
|
||||
- Writes go to a hidden sibling temp dir (`.tmp-<name>`), then the old
|
||||
folder is removed and the temp renamed into place. Not atomic (rename
|
||||
over a non-empty dir is impossible); the crash window can lose the
|
||||
folder — accepted for a local music queue, and a warning covers it.
|
||||
- Saving an existing name overwrites it (same temp-and-swap).
|
||||
|
||||
### D4 — Auto-persist through a latest-wins channel and one persister task
|
||||
|
||||
The playback loop must never block on disk. Every queue-state change
|
||||
sends a snapshot (`tracks`, `current_position`, `repeat`, `shuffle`)
|
||||
into a `tokio::sync::watch` channel (bounded, single slot, latest wins —
|
||||
a burst of resolve chunks coalesces naturally). A dedicated persister
|
||||
task awaits changes, debounces briefly, skips writes whose snapshot
|
||||
equals the last one written (broadcasts that only toggled the
|
||||
`resolving` flag stay free), and rewrites `queues/current/` per D3. Disk
|
||||
failures are warnings; playback is never affected. Send sites: the
|
||||
`broadcast_queue` funnel, the current-track broadcast in `play`, and the
|
||||
shuffle/repeat toggle handlers.
|
||||
|
||||
### D5 — Restore at startup, bespoke, never autoplay
|
||||
|
||||
`Replace(["/queues/current"])` through the normal resolve flow was
|
||||
rejected: it starts playback (a restarted server must stay silent), and
|
||||
it cannot restore the queue position. Instead, before the loops start
|
||||
serving, the server reads `queues/current/` directly — sorted listing,
|
||||
`TrackFile::parse`, `to_track` (identical semantics to the provider) —
|
||||
applies the tracks to the `QueueManager`, restores
|
||||
`current_position`/`repeat`/`shuffle` from the sidecar, and leaves
|
||||
`PlayState::Stopped`. A missing folder is a fresh start; a broken file is
|
||||
skipped with a warning like any listing.
|
||||
|
||||
### D6 — `SaveQueue` wiring
|
||||
|
||||
`rpc save_queue` sends `PlaybackCommand::SaveQueue { name, result_tx }`
|
||||
to the playback loop (single-writer discipline: only the loop may
|
||||
snapshot). The loop validates and snapshots, then hands the write to a
|
||||
spawned task so it never blocks on disk; the RPC reply reports the actual
|
||||
write result. Errors: invalid name (empty after trim, contains a path
|
||||
separator or NUL, starts with `.`, or is `current`) →
|
||||
`invalid_argument`; empty queue → `failed_precondition`; I/O →
|
||||
`internal`.
|
||||
|
||||
### D7 — TUI: `w` on the queue pane
|
||||
|
||||
New `Action::QueueSaveAs` bound to `w` in `Scope::Queue` ("Save queue
|
||||
as…"). It opens the existing input overlay with a new
|
||||
`InputPurpose::SaveQueue` (label `save queue`), no-op while the queue
|
||||
is empty. Submit sends `MessageFromUi::SaveQueue(name)` → the
|
||||
`SaveQueue` RPC. The saved queue appears under `/queues` on the next
|
||||
library visit — no push update needed.
|
||||
|
||||
### D8 — Out of scope (explicitly)
|
||||
|
||||
- ~~Renaming/deleting saved queues from the TUI~~ — delivered by the
|
||||
bookmarks feature (architecture/bookmarks.md D4): `/queues` mounts with
|
||||
an editable top level (reserved: `current`), so saved queues are
|
||||
renamable (`e`) and deletable (`d`). Creating nodes stays
|
||||
`NotSupported`.
|
||||
- Making the queues directory configurable; it is derived from the
|
||||
config dir.
|
||||
- Persisting the playback *position within the track*, autoplay on
|
||||
restore, or multiple current queues.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
server: crabidy-server {
|
||||
pb: Playback loop {
|
||||
q: "QueueManager (single writer)"
|
||||
}
|
||||
persister: "persister task" {
|
||||
w: "debounce, skip unchanged,\nwrite current/"
|
||||
}
|
||||
store: QueueStore {
|
||||
s: "validate name, tmp-and-swap"
|
||||
}
|
||||
orch: ProviderOrchestrator
|
||||
}
|
||||
|
||||
fs: "fsdy /fs\n(music root)"
|
||||
qfs: "fsdy /queues\n(config queues dir)"
|
||||
|
||||
disk: "config/crabidy/queues" {
|
||||
shape: cylinder
|
||||
cur: "current/ + .queue-state.toml"
|
||||
saved: "<name>/ per saved queue"
|
||||
}
|
||||
|
||||
server.pb -> server.persister: "watch channel\n(latest snapshot wins)"
|
||||
server.persister -> server.store: persist current
|
||||
server.pb -> server.store: "SaveQueue(name)\n(spawned write)"
|
||||
server.store -> disk
|
||||
server.orch -> qfs: "/queues/..."
|
||||
server.orch -> fs: "/fs/..."
|
||||
qfs -> disk: "list + parse (read only)"
|
||||
```
|
||||
|
||||
## Key flow: save, restart, reload
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
tui: TUI
|
||||
rpc: gRPC
|
||||
pb: Playback loop
|
||||
store: QueueStore
|
||||
orch: Orchestrator
|
||||
|
||||
tui -> rpc: "SaveQueue(road trip)"
|
||||
rpc -> pb: "PlaybackCommand::SaveQueue"
|
||||
pb -> store: "snapshot -> spawned write"
|
||||
store -> rpc: "queues/road trip/ written"
|
||||
rpc -> tui: OK
|
||||
tui -> pb: "(server restarts; restore reads current/)"
|
||||
tui -> orch: "GetLibraryNode(/queues)"
|
||||
orch -> tui: "children: [current, road trip]"
|
||||
tui -> pb: "ReplaceQueue([/queues/road%20trip])"
|
||||
pb -> orch: "resolve: links rewritten to targets"
|
||||
```
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Hand-written files in `queues/`** behave like any fs tree (broken
|
||||
files skipped with warnings). A hand-written `url`/`file` track keeps
|
||||
its `/queues/...` path when queued; persisting then links to that
|
||||
file — one hop, resolves fine.
|
||||
- **Queues past 9999 tracks** sort wrong beyond the 4-digit prefix;
|
||||
accepted (prefix width is a constant).
|
||||
- **Concurrent saves to the same name** race on the temp dir; last
|
||||
writer wins. Accepted for a single-user local server.
|
||||
- **Metadata drift**: a saved queue replays the metadata captured at
|
||||
save time, not the target's live metadata — consistent with
|
||||
fs-provider D2.
|
||||
- Open (future): deletable saved queues in the TUI; a `SaveQueue`
|
||||
confirmation/overwrite prompt; persisting the in-track position.
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# Roles and rights (basic-auth authorization)
|
||||
|
||||
crabidy listens on `0.0.0.0:50051`: anyone on the network can control
|
||||
playback and — worse — rename or delete library stores. The owner wants
|
||||
to hand out *limited* remotes: a co-host who may run the queue but not
|
||||
touch the library, and guests who may only add tracks.
|
||||
|
||||
## Problem statement
|
||||
|
||||
Three roles, credentialed by password hashes in the server config:
|
||||
|
||||
- **owner** — the normal user; everything.
|
||||
- **queue-owner** — anything on the queue (and playback), but no
|
||||
library writes: no `w`, no `W`.
|
||||
- **queue-appender** — may only add tracks to the end of the queue;
|
||||
no removal, no reordering, no queue settings.
|
||||
|
||||
## Assumptions (confirmed by the request, or decided here)
|
||||
|
||||
- Transport stays plain HTTP/2 gRPC. Basic auth over cleartext is
|
||||
acceptable on a trusted home network; anything else (Internet
|
||||
exposure) needs TLS termination in front (reverse proxy, VPN) and is
|
||||
out of scope. The README says so.
|
||||
- No auth configured (no `[auth]` section, or no hashes in it) means
|
||||
the server behaves exactly as before: open, everyone is owner. Auth
|
||||
switches on as soon as **any** role hash is configured; from then on
|
||||
every RPC requires credentials.
|
||||
- One password per role, not per person. The Basic-auth *username*
|
||||
selects the role (`owner`, `queue-owner`, `queue-appender`), the
|
||||
password is verified against that role's hash.
|
||||
|
||||
## Options considered
|
||||
|
||||
### Where to enforce
|
||||
|
||||
1. **Per-handler checks** inside `RpcService` (tonic interceptor
|
||||
authenticates, each of the ~25 handlers calls
|
||||
`require(role)?`). Idiomatic tonic, but *fail-open*: a future RPC
|
||||
that forgets the line is unprotected.
|
||||
2. **One tower layer** in front of the tonic service, mapping the
|
||||
gRPC method path to a minimum role, *default-deny* (unknown method
|
||||
⇒ owner only). Fail-closed, single enforcement point, zero handler
|
||||
churn; costs a small amount of manual HTTP plumbing for the
|
||||
deny response (gRPC trailers-only response).
|
||||
|
||||
**Decision: option 2.** Authorization is a security boundary; new
|
||||
RPCs must start locked. A unit test pins the method table against the
|
||||
proto service definition so an unmapped addition fails loudly.
|
||||
|
||||
### Hash scheme
|
||||
|
||||
PHC-format strings verified with the pure-Rust `argon2` crate
|
||||
(RustCrypto). Argon2id is the default the crate generates; any PHC
|
||||
variant the crate parses is accepted. bcrypt/scrypt support is not
|
||||
worth a second dependency. To keep users out of hash-tooling misery,
|
||||
`crabidy-server hash-password` reads a password on stdin and prints
|
||||
the PHC string to paste into the config.
|
||||
|
||||
### Verification cost
|
||||
|
||||
Argon2 verification is deliberately slow (tens of ms); per-keypress
|
||||
RPCs cannot re-verify. Successful credentials are cached in memory
|
||||
(`authorization` header value → role). Only *successful* verifications
|
||||
are cached, so the cache is bounded by the number of valid credentials
|
||||
(≤ 3); failures pay the full argon2 cost every time, which doubles as
|
||||
throttling.
|
||||
|
||||
## The rights matrix
|
||||
|
||||
Minimum role per RPC; higher roles include lower ones
|
||||
(owner ⊃ queue-owner ⊃ queue-appender):
|
||||
|
||||
- **queue-appender** (and up): `Init`, `GetLibraryNode`,
|
||||
`GetUpdateStream` (reads), `Append` (the one queue write), and
|
||||
`CreateLibraryNode` — creatable nodes are exactly the search terms,
|
||||
and guests must be able to search for what they append. (Search
|
||||
terms do persist in the owner's provider config; accepted — they are
|
||||
the mechanism of finding tracks, not library data.)
|
||||
- **queue-owner** (and up): every other queue and playback verb —
|
||||
`Queue`, `Replace`, `Remove`, `Insert`, `ClearQueue`, `SetCurrent`,
|
||||
`ToggleShuffle`, `ToggleRepeat`, `TogglePlay`, `Stop`, `Next`,
|
||||
`Prev`, `RestartTrack`, `ChangeVolume`, `ToggleMute`.
|
||||
- **owner** only: the library writes — `CaptureLibraryNode` (`w`/`W`),
|
||||
`SaveQueue` (writes `/queues`), `RenameLibraryNode`,
|
||||
`DeleteLibraryNode` (these two also cover search terms; a
|
||||
queue-owner can create terms but not rename/delete them — the
|
||||
server cannot cheaply tell a term from a bookmark store at this
|
||||
layer, so renames/deletes stay owner-only, fail-closed).
|
||||
- **unknown / future methods**: owner only.
|
||||
|
||||
Denied requests get `PERMISSION_DENIED`; missing or wrong credentials
|
||||
get `UNAUTHENTICATED`. Credentials are never logged (hard rule:
|
||||
secrets redacted).
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.config/crabidy/crabidy-server.toml` (new, read by the server —
|
||||
both standalone and inside `cbd`; absent file = auth off):
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
# One PHC hash per role; omit a role to disable it.
|
||||
# Generate with: crabidy-server hash-password
|
||||
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
queue_owner = "$argon2id$v=19$..."
|
||||
queue_appender = "$argon2id$v=19$..."
|
||||
```
|
||||
|
||||
`~/.config/crabidy/cbd-tui.toml` (client side, also as CLI flags):
|
||||
|
||||
```toml
|
||||
address = "http://127.0.0.1:50051"
|
||||
# Sent as HTTP basic auth when set. `user` is the role name.
|
||||
user = "queue-owner"
|
||||
password = "plaintext"
|
||||
```
|
||||
|
||||
The client config holds a *plaintext* password (client credentials
|
||||
always are); the README tells users to keep the file private. The TUI
|
||||
attaches `authorization: Basic …` to every request through a tonic
|
||||
interceptor; without configured credentials it sends no header, which
|
||||
keeps today's zero-config local setup working against an open server.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
tui: cbd-tui {
|
||||
cfg: "cbd-tui.toml user/password"
|
||||
interceptor: "auth interceptor\n(adds Basic header)"
|
||||
cfg -> interceptor
|
||||
}
|
||||
server: crabidy-server {
|
||||
layer: "AuthLayer (tower)\nheader → role → method check"
|
||||
rpc: RpcService
|
||||
cache: "verified-creds cache"
|
||||
cfg: "crabidy-server.toml [auth] hashes"
|
||||
cfg -> layer
|
||||
layer -> cache: hit = skip argon2
|
||||
layer -> rpc: authorized
|
||||
}
|
||||
tui.interceptor -> server.layer: every RPC
|
||||
server.layer -> tui: "UNAUTHENTICATED /\nPERMISSION_DENIED"
|
||||
```
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Denied-action UX**: the TUI currently logs RPC errors; an
|
||||
appender pressing a forbidden key sees a silent no-op (the log has
|
||||
the denial). A status-line hint is deferred until the flow has been
|
||||
felt in practice.
|
||||
- **Cleartext transport**: documented; TLS stays out of scope.
|
||||
- The update stream is authorized once at subscription; the stream
|
||||
itself only carries state every role may read.
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
# Search via creatable library nodes
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Crabidy's library is a lazily-fetched tree served by providers; today it is
|
||||
read-only and only exposes the user's favorites (playlists, followed artists).
|
||||
There is no way to find anything new. The desired interface (set by the user)
|
||||
is not a search dialog but **tree editing**: a search subtree under the
|
||||
provider in which the user *creates* nodes. Pressing `%` inside a creatable
|
||||
node prompts for a term; the term becomes a child node whose contents are the
|
||||
search results. Creatable places must be visibly marked in the UI.
|
||||
|
||||
`tidaldy` already has a `search()` explorer stub (`search/artists`, response
|
||||
dumped to the debug log), so the Tidal endpoint family
|
||||
(`search/tracks|artists|albums`) is known to exist but its response shape is
|
||||
unverified against our models.
|
||||
|
||||
## Decisions
|
||||
|
||||
Per the user's instruction, decisions from here on were taken autonomously and
|
||||
are recorded with rationale below (Options → Decision per topic). The one
|
||||
user-set constraint is the interface itself: creatable nodes in the tree, `%`
|
||||
to create, terms become persistent nodes.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Search is per-provider.** The search subtree lives at `/tidal/search`, not
|
||||
at the global root; a future provider brings its own. Consistent with prefix
|
||||
routing.
|
||||
- **Created nodes are ephemeral**, like every other piece of server state (the
|
||||
queue dies with the process, `SaveQueue` is a stub). Terms live in memory in
|
||||
the Tidal client for the process lifetime. Persistence is future work.
|
||||
- **"Editable" is scoped down to "creatable" for v1.** The described flow only
|
||||
needs create. The RPC is named so that `RemoveLibraryNode` /
|
||||
`RenameLibraryNode` can join it later without redesign (open question below).
|
||||
- **Search results are a view, not a copy.** Fetched fresh on node expansion,
|
||||
like playlists and albums are today. No caching, no staleness handling.
|
||||
|
||||
## Options considered
|
||||
|
||||
### 1. Mechanism: what does the wire contract look like?
|
||||
|
||||
**A — search-specific RPC** (`Search(query) → results`), TUI renders results
|
||||
in an ad-hoc pane. Least server work, but it is exactly the interface the
|
||||
user rejected: no tree nodes, nothing persistent to revisit, and a second
|
||||
navigation model in the TUI.
|
||||
|
||||
**B — generic node creation (chosen)**: `CreateLibraryNode(parent_path,
|
||||
title) → LibraryNode`, routed through `ProviderOrchestrator` by prefix like
|
||||
`GetLibraryNode`. The provider decides what creation *means* under a given
|
||||
parent; for `/tidal/search` the title is the search term. `LibraryNode` and
|
||||
`LibraryNodeChild` gain `bool is_creatable` so any client can mark creatable
|
||||
places generically. This matches the user's mental model, and the same RPC
|
||||
later covers other creatable things (e.g. new playlists).
|
||||
|
||||
### 2. Result shape: how do results hang in the tree?
|
||||
|
||||
**A — full nesting**: results live entirely under
|
||||
`/tidal/search/<term>/artists/<id>/<album>/<track>`. Self-contained
|
||||
navigation, but `TidalPath` grows a parallel copy of every variant (search
|
||||
versions of artist, album, both track kinds), `parse_path` needs recursive
|
||||
suffix matching, and the same Tidal entity gets yet another path identity.
|
||||
|
||||
**B — canonical-path children (chosen)**: `/tidal/search/<term>` carries the
|
||||
**track** results directly as its `tracks` (queueable in place, paths
|
||||
`/tidal/search/<term>/<track-id>` — one new track variant), while artist and
|
||||
album results are children whose paths are **canonical**
|
||||
(`/tidal/artists/<id>`, `/tidal/artists/<artist>/<album>`). Diving into an
|
||||
artist result reuses the existing artist/album/track machinery unchanged —
|
||||
`get_lib_node(/tidal/artists/<id>)` already works for any id, favorite or not.
|
||||
|
||||
Cost of B, accepted and documented: after diving from a search result into an
|
||||
artist, `h` (ascend) follows the canonical parent to `/tidal/artists`
|
||||
(favorites), not back to the search node. This is the existing
|
||||
"identity is context-dependent" trade-off from `architecture/overview.md`
|
||||
pointing the other way. B wins because it adds two path variants instead of
|
||||
five and cannot drift from the canonical browse behavior.
|
||||
|
||||
**Term nodes are not queueable** (decided during api-design): the server's
|
||||
`resolve_tracks` breadth-first-sweeps *all* children of a queueable node, so a
|
||||
queueable term node would turn "queue this search" into the top tracks *plus
|
||||
every album of every artist result*. `is_queable = false` on the term node
|
||||
keeps queueing explicit: individual track results (and any artist/album dived
|
||||
into) queue normally. Queueing the term node itself yields nothing, exactly
|
||||
like the existing category nodes (`playlists`, `artists`).
|
||||
|
||||
### 3. Term encoding in paths
|
||||
|
||||
Terms are user text; paths are `/`-separated and split by
|
||||
`path_segments`. A term like `AC/DC` or `100%` must not corrupt the tree.
|
||||
|
||||
**A — reject problematic characters**: surprising, and `%`-in-term is a
|
||||
plausible music query.
|
||||
**B — percent-encode the segment (chosen)**: the term is percent-encoded into
|
||||
the path segment (`AC/DC` → `AC%2FDC`); the node `title` keeps the raw term
|
||||
for display. Round-trip helpers live next to the other path helpers in
|
||||
`crabidy-core` (`encode_segment` / `decode_segment`), implemented with the
|
||||
`percent-encoding` crate (tiny, maintained, already in the dependency tree via
|
||||
`reqwest`/`url`).
|
||||
|
||||
Idempotency: creating an existing term returns the existing node rather than
|
||||
erroring; empty/whitespace-only terms are rejected with
|
||||
`ProviderError::InvalidInput` (new variant, also covers "this parent is not
|
||||
creatable" as `NotSupported`).
|
||||
|
||||
### 4. TUI text entry
|
||||
|
||||
First text input in the TUI. **A — reuse the bindings table** by adding an
|
||||
input scope: wrong tool — free text is not a finite set of chords.
|
||||
**B — a modal input line (chosen)**: `%` (Library scope, only when the
|
||||
*currently open* node — not the selected child — has `is_creatable`) opens a
|
||||
one-line input overlay at the bottom of the library pane. While it is open the
|
||||
bindings table is bypassed entirely except `Esc` (cancel) and `Enter`
|
||||
(submit); every other printable char appends, `Backspace` deletes. No cursor
|
||||
movement or paste handling in v1. On submit the TUI sends
|
||||
`CreateNode(parent, term)`, and navigates into the returned node.
|
||||
|
||||
The modality mechanism generalizes what the help modal introduced: the
|
||||
`(focus, help_open)` arguments of `bindings::lookup` become a single
|
||||
`InputMode`-aware gate (exact shape decided in api-design). `%` itself is a
|
||||
`BINDINGS` entry, so it shows up in the help modal like everything else.
|
||||
|
||||
### 5. Marking creatable nodes in the UI
|
||||
|
||||
Children with `is_creatable` render with a `%` marker suffix (e.g.
|
||||
`search [%]`) in `COLOR_SECONDARY`, and when the open node itself is
|
||||
creatable the pane title shows the hint (`search — % to add`). No new
|
||||
keybinding needed to discover it: the marker is the affordance, the help modal
|
||||
documents `%`.
|
||||
|
||||
## Structure
|
||||
|
||||
New/changed pieces, hatched by crate:
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
tui: cbd-tui {
|
||||
input: input overlay (new)
|
||||
bindings: "% binding + input-mode gate"
|
||||
library: creatable marker
|
||||
}
|
||||
|
||||
core: crabidy-core {
|
||||
proto: "proto: CreateLibraryNode rpc,\nis_creatable fields"
|
||||
trait: "ProviderClient::create_lib_node (new)"
|
||||
enc: "encode_segment / decode_segment (new)"
|
||||
}
|
||||
|
||||
server: crabidy-server {
|
||||
orchestrator: "ProviderOrchestrator:\nCreateLibraryNode command,\nprefix routing"
|
||||
}
|
||||
|
||||
tidaldy: tidaldy {
|
||||
search: "typed search requests (new)"
|
||||
terms: "search_terms: RwLock (new state)"
|
||||
paths: "TidalPath::Search, SearchTerm,\nSearchTrack (new variants)"
|
||||
}
|
||||
|
||||
tui.input -> core.proto: CreateLibraryNode
|
||||
core.proto -> server.orchestrator
|
||||
server.orchestrator -> tidaldy.terms: create under /tidal/search
|
||||
tidaldy.search -> core.trait: results as LibraryNode
|
||||
```
|
||||
|
||||
Path shape after the change (green = track paths, dashed = canonical jumps):
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
tidal: "/tidal"
|
||||
search: "/tidal/search (creatable)"
|
||||
term: "/tidal/search/<term>"
|
||||
strack: "/tidal/search/<term>/<track-id>" {
|
||||
style.fill: "#e8f4e8"
|
||||
}
|
||||
artists: "/tidal/artists"
|
||||
artist: "/tidal/artists/<id>"
|
||||
album: "/tidal/artists/<id>/<album-id>"
|
||||
|
||||
tidal -> search
|
||||
search -> term: created via %
|
||||
term -> strack: track results
|
||||
term -> artist: artist result (canonical path) {style.stroke-dash: 3}
|
||||
term -> album: album result (canonical path) {style.stroke-dash: 3}
|
||||
artists -> artist
|
||||
artist -> album
|
||||
```
|
||||
|
||||
Create flow:
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
|
||||
user: User
|
||||
tui: cbd-tui
|
||||
rpc: gRPC service
|
||||
orch: ProviderOrchestrator
|
||||
tidal: tidaldy::Client
|
||||
api: Tidal REST API
|
||||
|
||||
user -> tui: "% inside /tidal/search"
|
||||
tui -> tui: open input overlay (bindings bypassed)
|
||||
user -> tui: types term, Enter
|
||||
tui -> rpc: CreateLibraryNode(/tidal/search, term)
|
||||
rpc -> orch: ProviderCommand::CreateLibraryNode
|
||||
orch -> tidal: create_lib_node(parent, title)
|
||||
tidal -> tidal: store term (idempotent)
|
||||
tidal -> api: "search/tracks|artists|albums?query=term"
|
||||
api -> tidal: results
|
||||
tidal -> orch: "LibraryNode /tidal/search/<enc(term)>"
|
||||
orch -> rpc: node
|
||||
rpc -> tui: node
|
||||
tui -> tui: ReplaceLibraryNode (navigate into results)
|
||||
```
|
||||
|
||||
## Boundaries and interfaces (high level)
|
||||
|
||||
- **proto**: `rpc CreateLibraryNode(CreateLibraryNodeRequest) returns
|
||||
(CreateLibraryNodeResponse)`; request = `parent_path`, `title`; response =
|
||||
the created `LibraryNode`. `LibraryNode.is_creatable = 7`,
|
||||
`LibraryNodeChild.is_creatable = 4` — additive, wire-compatible.
|
||||
- **ProviderClient**: new required method `create_lib_node(parent_path,
|
||||
title) -> Result<LibraryNode, ProviderError>`. `ProviderError` gains
|
||||
`NotSupported` and `InvalidInput` variants. The orchestrator's synthetic
|
||||
root and the server's mock provider return `NotSupported`.
|
||||
- **ProviderCommand**: new `CreateLibraryNode { parent_path, title,
|
||||
result_tx }`, same bounded(1)-reply pattern and 30s-timeout discipline as
|
||||
the existing commands.
|
||||
- **tidaldy**: `TidalPath::{Search, SearchTerm, SearchTrack}`; typed
|
||||
`search_tracks/search_artists/search_albums` (first page, limit 20 per
|
||||
category — search is exploratory, not exhaustive; `make_paginated_request`'s
|
||||
fetch-everything loop is wrong for it); `search_terms:
|
||||
RwLock<Vec<String>>` following the existing login-state locking discipline
|
||||
(never held across await).
|
||||
- **cbd-tui**: input overlay state on `App`; `MessageFromUi::CreateNode`;
|
||||
`rpc.rs` client method; `%` in `BINDINGS` (Library scope); creatable marker
|
||||
in the library list rendering.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Unverified search response shape.** Our `Track`/`Artist`/`Album` models
|
||||
may not match `search/*` payloads (the explorer stub exists precisely
|
||||
because this was unexplored). Mitigation: first implement task runs the
|
||||
explorer request against the live API and locks the models down; if the
|
||||
shapes differ, only `tidaldy::models` grows search-specific wrappers.
|
||||
- **Trait change ripples.** Adding a required `ProviderClient` method touches
|
||||
the server's mock provider and any test doubles. Deliberate: a default
|
||||
"not supported" impl would hide missing implementations silently.
|
||||
- **Input overlay vs. terminal reality.** Paste arrives as a burst of char
|
||||
events (fine: they append), IME composition is untested. v1 accepts this.
|
||||
- **Term nodes are invisible to other clients** until they re-fetch
|
||||
`/tidal/search` — there is no library update stream. Accepted; browsing is
|
||||
pull-based today.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Delete/rename of created nodes (`RemoveLibraryNode`?) — the RPC family and
|
||||
`is_creatable` flag anticipate it; not in v1.
|
||||
- Should created terms persist across restarts (they'd fit a small TOML next
|
||||
to the token store)? Deferred with the queue-persistence question.
|
||||
- Combined-search ranking: v1 shows tracks, then artists, then albums in
|
||||
fixed category order; relevance interleaving would need the combined
|
||||
`search` endpoint.
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# Frequency spectrum visualizer
|
||||
|
||||
A row of frequency bars under the track progress in the TUI, on by
|
||||
default, turn-offable in the client config. The reference the user gave,
|
||||
[BeSpec](https://github.com/BeSpec-Dev/BeSpec), is a standalone
|
||||
egui/wgpu app that captures *local* system-audio loopback and runs a
|
||||
2048-point realfft — we borrow its DSP shape, not its capture model.
|
||||
|
||||
## The core problem: where does the audio live?
|
||||
|
||||
The audio is decoded and played **on the server** (the `audio-player`
|
||||
crate, rodio). The TUI — and the web client — are gRPC clients that may
|
||||
run on another machine (the stated setup: `cbd` local, `cbd-tui`
|
||||
pointed at a Raspberry Pi, `architecture/client-configs.md`). A local
|
||||
loopback capture in the client, BeSpec-style, would therefore show
|
||||
nothing (or the wrong machine's audio) for a remote client.
|
||||
|
||||
So the spectrum must be produced where the samples are — the server —
|
||||
and streamed to clients like every other bit of live state.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — server taps the samples, computes the FFT, streams bins
|
||||
|
||||
- **Tap** (`audio-player`): the decoded source is wrapped in a
|
||||
`TappingSource` that copies each played frame (downmixed to mono)
|
||||
into a fixed lock-free ring (`SpectrumTap`, 2048 `f32` slots, atomic
|
||||
write index). It runs on rodio's audio thread, so it does the
|
||||
absolute minimum — one store per sample, no locks, no allocation —
|
||||
and benign read/write races are fine for a visualizer.
|
||||
- **FFT** (`crabidy-server`): a task ticks at ~20 fps, snapshots the
|
||||
ring, applies a Hann window + realfft, folds the magnitude spectrum
|
||||
into a small number of log-spaced bins (musically even), normalizes
|
||||
to `0..1`, and broadcasts them.
|
||||
- **Stream**: a new `SpectrumFrame { bins }` on the existing
|
||||
`GetUpdateStream` (oneof variant), at a handful of bins × ~20 fps —
|
||||
~2 KB/s, negligible next to the audio it describes.
|
||||
|
||||
Rejected: client-side loopback capture (breaks the remote client, the
|
||||
whole reason clients exist); sending raw PCM to clients (orders of
|
||||
magnitude more bandwidth, and every client would re-run the FFT).
|
||||
|
||||
### D2 — idle detection without touching the control path
|
||||
|
||||
The tap increments a frame counter on every write. The FFT task
|
||||
compares the counter between ticks: advancing ⇒ audio is flowing, emit
|
||||
bins; unchanged ⇒ paused/stopped/between tracks, emit a single
|
||||
all-zero frame (bars fall to the floor) and then stay quiet until it
|
||||
moves again. No extra `is_playing` round-trips onto the player command
|
||||
channel, and no frozen bars on pause.
|
||||
|
||||
### D3 — the config toggle is client-side display; the server always offers it
|
||||
|
||||
`spectrum = true` (default) in `cbd-tui.toml` / `cbd.toml` shows the
|
||||
bars; `false` hides them. The toggle is a *display* choice — the
|
||||
server always computes and streams when something is playing. At
|
||||
household scale one small FFT task at 20 fps is not worth gating on a
|
||||
per-client preference, and keeping the server unconditional means any
|
||||
client (TUI, web) can show bars without a negotiation. The FFT only
|
||||
runs while audio is actually flowing (D2), so an idle server is idle.
|
||||
|
||||
### D4 — rendering
|
||||
|
||||
The TUI draws the bars in the now-playing pane, directly under the
|
||||
progress gauge, as a single row of vertical block glyphs
|
||||
(`▁▂▃▄▅▆▇█`) whose heights track the bins, in the accent color. The web
|
||||
client renders the same bins as CSS-height bars for parity. Both simply
|
||||
consume the latest `SpectrumFrame`; neither computes anything.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
audio: "audio-player (server)" {
|
||||
dec: decoder
|
||||
tap: "TappingSource\n→ SpectrumTap ring"
|
||||
sink: rodio sink
|
||||
dec -> tap -> sink
|
||||
}
|
||||
fft: "spectrum task\nHann + realfft →\nlog bins, ~20fps"
|
||||
stream: "GetUpdateStream\nSpectrumFrame{bins}"
|
||||
tui: "TUI now-playing\nblock-glyph bars"
|
||||
web: "web client\nCSS bars"
|
||||
audio.tap -> fft: snapshot
|
||||
fft -> stream
|
||||
stream -> tui
|
||||
stream -> web
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- **Audio-thread cost**: the tap must stay trivial; anything more than
|
||||
a store per sample risks underruns. No locks, no allocation, no
|
||||
logging on that path.
|
||||
- **Torn reads**: the FFT reads the ring while the audio thread writes
|
||||
it. Accepted — a visualizer tolerates the occasional stale/mixed
|
||||
sample; correctness of playback is never affected (the tap only
|
||||
*observes*).
|
||||
- **realfft** is pure Rust (no system libs), so it does not complicate
|
||||
packaging.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# TUI `/` search filter
|
||||
|
||||
Pressing `/` in the library or queue pane opens a search input that
|
||||
filters that pane's items live as you type (case-insensitive
|
||||
substring). A small feature; recorded here for the couple of decisions
|
||||
that were not obvious.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — filter, not jump
|
||||
|
||||
Vim's `/` jumps to the next match; here `/` *narrows* the visible list
|
||||
to matching rows. For a music library ("show me everything with
|
||||
'radiohead'") filtering is the more useful reading of "search in the
|
||||
items", and it composes with the existing queue/mark actions — you
|
||||
filter, then act on what is left.
|
||||
|
||||
### D2 — the filter lives on the pane, the input on the app
|
||||
|
||||
The pane (`Library`/`Queue`) owns a `Filter` (the query plus the list
|
||||
of visible real indices). The `/` input mode (`App::search`) only holds
|
||||
the editing buffer and which pane is focused. So the filter *survives*
|
||||
closing the input: `Enter` keeps it applied and returns to navigation,
|
||||
`Esc` clears it. This matches the other modal overlays (input, confirm)
|
||||
in how keys are routed while it is open.
|
||||
|
||||
### D3 — view indices vs real indices
|
||||
|
||||
The panes keep their full item list; the filter maps between the
|
||||
*view* index (what the selection bar and `StatefulList` navigation key
|
||||
off) and the *real* index into the list. This matters most for the
|
||||
queue: removal and set-current send **real queue positions** to the
|
||||
server, so a filtered selection must map back before it is sent —
|
||||
otherwise `d` on the third visible row would remove the wrong track.
|
||||
Marks likewise live on the full list, so a marked-but-hidden item still
|
||||
counts when queueing.
|
||||
|
||||
Because `StatefulList` already routes through `get_size`/`select`/
|
||||
`selected`, pointing those at the filtered view made all the movement
|
||||
keys (`j`/`k`/`g`/`G`/`Ctrl-d`/`Ctrl-u`) work on the filtered list with
|
||||
no per-key changes.
|
||||
|
||||
### D4 — lifecycle
|
||||
|
||||
- **Library**: entering a node is a fresh listing, so search mode is
|
||||
reset there (a stale filter from the previous folder would be
|
||||
confusing).
|
||||
- **Queue**: the queue is re-sent constantly (position ticks,
|
||||
resolving), so an active search is *preserved* across updates and
|
||||
only its visible set is recomputed.
|
||||
|
||||
## Scope
|
||||
|
||||
Implemented in the TUI only, per the request. The web client
|
||||
(`architecture/web-client.md`) could mirror it later for parity; noted
|
||||
as a follow-up, not done here.
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
# Web client (cbd-web)
|
||||
|
||||
A browser client with the same functionality as the TUI, served by
|
||||
`crabidy-server` itself so that "open `http://server:50051`" is the
|
||||
whole install story. Leptos, pure modern CSS, crab orange-red accent,
|
||||
light and dark themes.
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
The TUI covers the owner's desk. Phones, tablets, and guests (see
|
||||
`architecture/roles-auth.md` — queue-owner / queue-appender roles
|
||||
exist precisely for them) need a client without a terminal. It must
|
||||
not be a second, drifting implementation of the protocol surface: the
|
||||
web client should speak the same gRPC contract as the TUI, feature for
|
||||
feature: library browsing, search terms, queue manipulation, playback
|
||||
control, bookmarks (`w`), captures (`W`, with progress and confirmed
|
||||
deletion), and the live update stream.
|
||||
|
||||
## Assumptions (confirmed or decided)
|
||||
|
||||
- "Exactly the same functionality as the TUI" means the same *actions
|
||||
and information*, not a terminal emulation: every TUI binding has a
|
||||
clickable equivalent, and the familiar keyboard bindings (j/k/h/l,
|
||||
Tab, %, e, d, w, W, …) also work on desktop browsers.
|
||||
- "Local first" is interpreted for what this app *is* — a remote
|
||||
control for live server state (one audio output, one queue). There
|
||||
is no offline-editing story to sync: a CRDT layer (as in the
|
||||
`web_client_example_workspace` template, automerge et al.) would
|
||||
model conflicts that cannot occur and add a heavy dependency wall.
|
||||
Local-first here means: **client-side rendered, all assets local
|
||||
(no CDN), session state and credentials in the browser, library
|
||||
listings cached in memory like the TUI's, optimistic UI where
|
||||
safe, and graceful reconnect/backoff when the server disappears.**
|
||||
This is a deliberate, documented deviation from the example
|
||||
template.
|
||||
- The example workspace informs the toolchain (leptos 0.8, trunk,
|
||||
wasm32 target in devenv, workspace layout, lint posture) — not the
|
||||
runtime architecture (SSR/hydration + WebSocket sync). We build a
|
||||
pure CSR app: the server side must stay tonic, not become a leptos
|
||||
SSR host.
|
||||
- One port for everything: gRPC (TUI), gRPC-web (browser), and static
|
||||
assets are all served on `LISTEN_ADDR` (50051). No second listener,
|
||||
no CORS story needed (same origin).
|
||||
|
||||
## Options considered
|
||||
|
||||
### Browser transport
|
||||
|
||||
1. **gRPC-web with the existing proto** — server wraps the existing
|
||||
tonic service in `tonic-web` (0.14.6, matches our tonic); the
|
||||
browser uses the *same generated clients* from `crabidy-core` over
|
||||
`tonic-web-wasm-client` (0.9.1, tonic ^0.14). Server streaming
|
||||
(GetUpdateStream) is supported. The 24-RPC surface and all types
|
||||
are shared — parity is structural, not aspirational. The auth
|
||||
layer keeps working unchanged: gRPC-web POSTs to the same
|
||||
`/crabidy.v1.CrabidyService/…` paths, `minimum_role` sees them
|
||||
identically, and the browser can set the `authorization` header.
|
||||
2. REST + WebSocket bridge — a second API surface to hand-write,
|
||||
secure, and keep in sync. Rejected.
|
||||
|
||||
**Decision: gRPC-web (1).**
|
||||
|
||||
### Serving the app
|
||||
|
||||
1. **Embed the built assets in the server binary** (`include_dir` of
|
||||
`cbd-web/dist`) behind a cargo feature `web-ui`, **default on**
|
||||
(the request), compiled into `crabidy-server` and thus `cbd`. The
|
||||
single binary stays self-contained.
|
||||
2. Serve from a directory on disk at runtime. Flexible but breaks the
|
||||
single-binary story and invites path confusion. Rejected (can be
|
||||
added later as an override).
|
||||
|
||||
**Decision: embed (1).** tonic 0.14's router is axum-based:
|
||||
`Routes::into_axum_router()` lets us add plain axum routes for `/`,
|
||||
`/pkg/…` and friends next to the gRPC paths. Static assets are served
|
||||
without authentication (the app shell is public; every RPC behind it
|
||||
stays gated) — same posture as any login page.
|
||||
|
||||
### Building the wasm app
|
||||
|
||||
`cbd-web` is a workspace member built by **trunk** into
|
||||
`cbd-web/dist`. Embedding happens through a small
|
||||
`crabidy-server/build.rs` that copies `cbd-web/dist` into `OUT_DIR`
|
||||
when present and otherwise generates a **placeholder page** ("web UI
|
||||
not built — run `devenv shell -- trunk build --release` in
|
||||
`cbd-web/`") so that:
|
||||
|
||||
- plain `cargo build` never fails and never needs wasm tooling
|
||||
(default-on feature stays harmless),
|
||||
- `build.rs` never invokes cargo-in-cargo (trunk runs cargo; nesting
|
||||
it inside a build script risks target-dir lock deadlocks),
|
||||
- rebuilding after a trunk run re-embeds automatically
|
||||
(`rerun-if-changed=cbd-web/dist`).
|
||||
|
||||
devenv gains `trunk`, `wasm-bindgen-cli`, `binaryen` and the
|
||||
`wasm32-unknown-unknown` rustup target, so the documented build is
|
||||
two commands. The dev loop (`trunk serve` with a proxy to a running
|
||||
server) is documented in `cbd-web/README.md`.
|
||||
|
||||
### crabidy-core on wasm
|
||||
|
||||
The generated gRPC client must compile to `wasm32-unknown-unknown`.
|
||||
`crabidy-core` trims its tonic dependency to
|
||||
`default-features = false, features = ["codegen"]` (no transport, no
|
||||
router); native crates keep the full tonic via their own dependency
|
||||
edges, and cargo's per-target feature unification does the rest.
|
||||
Native-only pieces of crabidy-core that do not build on wasm (config
|
||||
loading via `dirs`, clap plumbing) move behind a
|
||||
`cfg(not(target_arch = "wasm32"))` gate / target-specific
|
||||
dependencies. The proto types, paths helpers, and client stubs are
|
||||
the wasm surface.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
browser: Browser {
|
||||
app: "cbd-web (leptos CSR wasm)"
|
||||
store: "localStorage:\ncredentials, theme"
|
||||
app -> store
|
||||
}
|
||||
server: "crabidy-server :50051" {
|
||||
axum: axum router
|
||||
static: "embedded cbd-web/dist\n(feature web-ui, default on)"
|
||||
grpcweb: "tonic-web layer"
|
||||
auth: AuthLayer
|
||||
rpc: CrabidyService
|
||||
axum -> static: "GET /, /pkg/…"
|
||||
axum -> grpcweb: "POST /crabidy.v1.…"
|
||||
grpcweb -> auth -> rpc
|
||||
}
|
||||
tui: cbd-tui
|
||||
browser.app -> server.axum: "gRPC-web (fetch,\nauthorization header)"
|
||||
tui -> server.axum: gRPC (HTTP/2)
|
||||
```
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
title: cbd-web internals {near: top-center}
|
||||
rpc: "rpc.rs\ntonic-web-wasm-client,\nsame crabidy-core stubs"
|
||||
state: "state.rs\nsignals: queue, play\nstate, volume, library\n+ cache, captures"
|
||||
stream: "stream task\nGetUpdateStream →\nsignals, reconnect backoff"
|
||||
ui: "components\nLibrary, Queue, NowPlaying,\ndialogs (name, y/N, login)"
|
||||
keys: "keymap\nTUI-compatible bindings"
|
||||
rpc -> stream -> state
|
||||
ui -> rpc: actions
|
||||
state -> ui: render
|
||||
keys -> ui
|
||||
```
|
||||
|
||||
## Functional parity map (TUI → web)
|
||||
|
||||
| TUI | Web |
|
||||
| --- | --- |
|
||||
| library j/k/h/l, Tab, Enter | list + click/keys, back button, panes |
|
||||
| `%` create search term | "+" affordance & `%` key → name dialog |
|
||||
| `e` rename, `d` delete (+capture y/N) | item actions & keys → dialogs |
|
||||
| `w`/`W` bookmark/capture + progress | keys/actions → dialog, progress |
|
||||
| marks (`*`), queue/append/replace/insert | multi-select & keys |
|
||||
| queue ops, x remove, C/c clear, s save | buttons & keys |
|
||||
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
|
||||
| skipped tracks red | same, via `Track.is_skipped` |
|
||||
| update stream reconnect | same, backoff + disconnect banner |
|
||||
| auth via config file | login form on `UNAUTHENTICATED`, localStorage |
|
||||
| `?` help modal | `?` help overlay listing keys |
|
||||
|
||||
## Styling
|
||||
|
||||
Pure hand-written CSS (one `style.css`, no framework, no CDN):
|
||||
custom properties for the palette, `color-scheme: light dark` +
|
||||
`light-dark()`/`prefers-color-scheme` with a manual override toggle
|
||||
(persisted), CSS nesting, `color-mix()` for derived tones, grid/flex
|
||||
layout, `@media` breakpoints for phone layout (library and queue as
|
||||
switchable panes, like Tab in the TUI). Accent color "crab
|
||||
orange-red": `--accent: oklch(0.62 0.19 35)` (≈ #e14b2a) with hover /
|
||||
active derivations via `color-mix`. Focus rings and selection bars
|
||||
reuse the accent; skipped tracks and destructive confirms use the
|
||||
existing red semantics.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- `tonic-web-wasm-client` is a third-party crate; if it ever lags a
|
||||
tonic bump, the pinned pair (tonic 0.14 / 0.9.1) keeps building —
|
||||
upgrade both in lockstep.
|
||||
- gRPC-web server streaming holds one HTTP connection per browser
|
||||
tab; fine at household scale.
|
||||
- The browser cannot play the audio (output is the server's
|
||||
speakers); a later "play in browser" feature would need a separate
|
||||
audio streaming endpoint — explicitly out of scope.
|
||||
- Leptos component logic is hard to unit-test headlessly; logic that
|
||||
matters (path/selection state machines, formatting) lives in plain
|
||||
modules with native `#[test]`s, components stay thin.
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
# YouTube provider (ytdy)
|
||||
|
||||
> **Extraction engine superseded** by `youtube-rustypipe.md`: the
|
||||
> `yt-dlp` subprocess (D1, D5, the `binary` setting, and the
|
||||
> `bestaudio` format choice) was replaced with the pure-Rust
|
||||
> `rustypipe` client after playback turned out broken (bestaudio =
|
||||
> Opus, which the rodio/symphonia player cannot decode) and the Python
|
||||
> subprocess proved unwanted. The tree shape, search-term store, path
|
||||
> scheme, and optional-login gating described here still hold.
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
A new library provider for YouTube, mounted at `/youtube`:
|
||||
|
||||
- **Search** works without any login — creatable search-term nodes like
|
||||
`/tidal/search` (`%` creates a term, results list as tracks).
|
||||
- **Login is optional.** When configured, the user's **playlists** appear
|
||||
as an extra subtree; without it, the provider still works (search
|
||||
only).
|
||||
- **Captures** (`W`, download) must work on YouTube nodes — search
|
||||
results and playlists are downloadable.
|
||||
|
||||
## Assumptions (confirmed)
|
||||
|
||||
- The captures machinery is provider-agnostic: anything that answers
|
||||
`get_lib_node`/`get_urls_for_track` and raises `is_downloadable` gets
|
||||
`w`/`W` for free — no new wire or TUI work at all.
|
||||
- The TUI's creatable/editable/deletable node flows (`%`/`e`/`d`) are
|
||||
generic; mirroring tidal's search-term semantics costs no TUI change.
|
||||
- `pkgs.yt-dlp` (2026.06.09) exists in nixpkgs; the devenv already pins
|
||||
the toolchain, so the engine binary is declared, not assumed.
|
||||
- The audio player streams plain https URLs; googlevideo stream URLs
|
||||
(from `bestaudio`) are plain https and also downloadable with the
|
||||
captures reqwest client.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Extraction engine: `yt-dlp` subprocess
|
||||
|
||||
Options considered:
|
||||
|
||||
- *(a)* **Pure-Rust extractor crates** (`rustypipe`, `rusty_ytdl`): no
|
||||
external binary, but they chase YouTube's extraction changes with
|
||||
small maintainer teams, and logged-in user playlists are weakly or not
|
||||
supported.
|
||||
- *(b)* **Invidious/Piped instances**: no extraction code at all, but a
|
||||
hard runtime dependency on third-party servers of unpredictable
|
||||
availability — worse than a local binary for a self-hosted player.
|
||||
- *(c)* **`yt-dlp` as a subprocess** with `-J` JSON output: the de-facto
|
||||
standard extractor, fastest to track YouTube changes, supports search
|
||||
(`ytsearchN:`), playlists, cookies-based login, and direct stream
|
||||
URLs. Cost: a non-Rust runtime dependency and subprocess plumbing.
|
||||
|
||||
**Decision: (c).** The binary is declared in `devenv.nix` (dev) and is a
|
||||
documented runtime requirement (deploys). All calls go through one
|
||||
`Engine` seam (`tokio::process::Command`, `kill_on_drop`, per-call
|
||||
timeout, bounded stdout, `serde_json` parsing) so tests fake the binary
|
||||
with a script and a future pure-Rust engine stays swappable.
|
||||
|
||||
### D2 — Crate `ytdy`, mounted at `/youtube`, non-fatal init
|
||||
|
||||
New workspace crate `ytdy` implementing `ProviderClient`, following
|
||||
`tidaldy`'s shape. `init` probes `<binary> --version` (with timeout);
|
||||
a missing or broken binary disables the provider with a warning — the
|
||||
server and every other provider keep running. Settings (`ytdy.toml`):
|
||||
|
||||
```toml
|
||||
binary = "yt-dlp" # optional; PATH lookup by default
|
||||
cookies = "/path/cookies.txt" # optional; presence = "logged in"
|
||||
search_results = 20 # ytsearchN cap
|
||||
```
|
||||
|
||||
The orchestrator gains `youtube_client: Option<Arc<ytdy::Client>>` and
|
||||
`/youtube` routing arms (same completeness as the other providers);
|
||||
`get_lib_root` lists `youtube` only when the probe succeeded.
|
||||
|
||||
### D3 — Tree shape
|
||||
|
||||
- `/youtube` — children: `search` (always, `is_creatable`), `playlists`
|
||||
(only when cookies are configured).
|
||||
- `/youtube/search/<term>` — created via `%` like tidal search; terms
|
||||
live in memory (`RwLock<Vec<String>>`, dedup, recreated implicitly on
|
||||
stale paths), renamable and deletable. The node lists the top
|
||||
`search_results` results as **tracks** (`ytsearchN:<term>`,
|
||||
`--flat-playlist`); it is queueable (results are homogeneous tracks,
|
||||
unlike tidal's mixed search).
|
||||
- `/youtube/playlists` — the user's playlists as children
|
||||
(`https://www.youtube.com/feed/playlists` with cookies, flat).
|
||||
- `/youtube/playlists/<pid>` — playlist entries as tracks.
|
||||
- Track paths: `<node>/<videoid>`; metadata from the flat entries
|
||||
(title, uploader as artist, duration). `get_metadata_for_track` runs
|
||||
a single-video `-J` when called directly.
|
||||
|
||||
### D4 — Streams, downloads, login
|
||||
|
||||
- `get_urls_for_track`: `yt-dlp -f bestaudio/best -g --no-playlist
|
||||
<video>` → the stream URL(s). Cookies are passed to **every** call
|
||||
when configured (search and streams benefit too, e.g. age-gated
|
||||
videos).
|
||||
- Downloadability mirrors tidal's central rule: a node is downloadable
|
||||
when it is queueable or lists tracks; children mirror `is_queable`.
|
||||
`extension_for` gains `audio/webm → webm` (bestaudio is usually opus
|
||||
in webm; googlevideo URLs carry no path extension).
|
||||
- "Logged in" is exactly "a cookies file is configured and readable at
|
||||
init" — no OAuth flow of our own, no credential storage beyond the
|
||||
user-provided file. The cookies path is config; its **contents are a
|
||||
secret** and must never appear in logs or error reports.
|
||||
|
||||
### D5 — Subprocess discipline
|
||||
|
||||
- One shared `Engine` with the binary path; every invocation:
|
||||
`--no-warnings -J` (or `-g`), argument-list only (never shell),
|
||||
per-call timeout (default 60 s, configurable for tests), stdout
|
||||
capped, stderr summarized into warnings (never echoed wholesale — it
|
||||
can contain URLs).
|
||||
- Errors are typed (`EngineError`: spawn/timeout/exit-status/parse) and
|
||||
map to `ProviderError::FetchError`/`MalformedPath` at the trait
|
||||
boundary; no panic on any subprocess condition.
|
||||
- Calls run sequentially per request (no engine-level queue); the
|
||||
orchestrator already spawns long walks.
|
||||
|
||||
### D6 — Out of scope (explicitly)
|
||||
|
||||
- OAuth/device login flows, cookie refresh, or storing credentials.
|
||||
- Uploads, likes, subscriptions feeds, comments; YouTube Music.
|
||||
- Caching search or playlist results across calls (each listing is a
|
||||
fresh subprocess call, like tidal's fresh HTTP calls).
|
||||
- SponsorBlock, chapters, DRM-protected content (skipped as unplayable).
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
server: crabidy-server {
|
||||
orch: ProviderOrchestrator
|
||||
}
|
||||
|
||||
ytdy: "ytdy (crate)" {
|
||||
client: "Client\n(ProviderClient)"
|
||||
terms: "search terms\n(in-memory, like tidal)"
|
||||
engine: "Engine\n(subprocess seam)"
|
||||
client -> terms
|
||||
client -> engine
|
||||
}
|
||||
|
||||
bin: "yt-dlp binary\n(devenv / PATH)" {
|
||||
shape: hexagon
|
||||
}
|
||||
|
||||
yt: "YouTube" { shape: cloud }
|
||||
cookies: "cookies.txt\n(optional, user-provided)" { shape: page }
|
||||
|
||||
server.orch -> ytdy.client: "/youtube/..."
|
||||
ytdy.engine -> bin: "argv + -J, timeout,\nbounded stdout"
|
||||
bin -> yt
|
||||
cookies -> ytdy.engine: "--cookies (when set)"
|
||||
```
|
||||
|
||||
## Key flow: search, then W
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
tui: TUI
|
||||
orch: Orchestrator
|
||||
y: ytdy
|
||||
e: yt-dlp
|
||||
|
||||
tui -> orch: "% on /youtube/search: 'lofi'"
|
||||
orch -> y: "create_lib_node"
|
||||
y -> e: "ytsearch20:lofi -J --flat-playlist"
|
||||
e -> y: "entries (id, title, uploader, duration)"
|
||||
y -> tui: "term node: 20 tracks, downloadable"
|
||||
tui -> orch: "W → CaptureLibraryNode(download)"
|
||||
orch -> y: "per track: get_urls_for_track"
|
||||
y -> e: "-f bestaudio -g <id>"
|
||||
e -> y: "googlevideo url"
|
||||
orch -> orch: "captures store: stream to\n/captures/<name>/ (webm + toml)"
|
||||
```
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **`feed/playlists` extraction**: the exact yt-dlp invocation for "my
|
||||
playlists" remains **live-unvalidated** (no cookies were available
|
||||
during development; search, streams, and download captures were
|
||||
validated live). Fallback if it turns out wrong: adjust the feed URL
|
||||
in `playlists_node`, or bookmark playlist URLs as search terms.
|
||||
- **yt-dlp breakage/drift**: extraction can break until the devenv pin
|
||||
is bumped; errors stay typed and non-fatal.
|
||||
- **Stream URL lifetime**: googlevideo URLs expire (~6 h); captures
|
||||
download immediately after resolving, playback resolves on play —
|
||||
both within the window.
|
||||
- **Terms of service**: downloads are personal-use copies of streams the
|
||||
client can already play, same stance as tidal captures.
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# YouTube provider on rustypipe
|
||||
|
||||
> **Amended the same day** by D2-revised below: pure-Rust stream
|
||||
> *fetching* turned out to be blocked by YouTube's PO-token
|
||||
> enforcement, so stream URLs go through a minimal `yt-dlp` sidecar
|
||||
> again while all metadata stays on rustypipe.
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
The first `ytdy` iteration shelled out to `yt-dlp` (a Python tool). Two
|
||||
problems surfaced in real use:
|
||||
|
||||
1. **Playback was broken.** Search worked, but picked tracks never
|
||||
played: `yt-dlp -f bestaudio` selects WebM/**Opus**, and the player is
|
||||
rodio + symphonia (`symphonia-all`) — symphonia has **no Opus
|
||||
decoder**. The stream downloaded fine and then failed to decode.
|
||||
(Download captures of YouTube tracks had the same latent problem:
|
||||
`.webm` files that the local player cannot decode.)
|
||||
2. The user does not want a Python subprocess in the loop.
|
||||
|
||||
## Evaluation of pure-Rust extractors (2026-07-21, live-tested)
|
||||
|
||||
- `rusty_ytdl` 0.7.4 — search works, but **stream URLs come back
|
||||
empty** and `stream()` fails with "Video source empty": its cipher
|
||||
handling has fallen behind YouTube's rotation (last release early
|
||||
2025).
|
||||
- `rustube` 0.6.0 — unmaintained since ~2022; not tested further.
|
||||
- `rust-yt-downloader` 0.1.0 — a small CLI, not a library engine.
|
||||
- `rustypipe` 0.11.4 — **works end to end**: search, video details,
|
||||
playlists, and `player()` returns deciphered stream URLs (verified:
|
||||
HTTP 206 fetch, and the full itag-140 m4a of a test video decodes
|
||||
through `rodio::Decoder` — 44.1 kHz samples out). Actively maintained
|
||||
(NewPipe-inspired Innertube client).
|
||||
|
||||
**Decision: replace the subprocess engine with `rustypipe`.** It removes
|
||||
the Python dependency *and* the format problem: we pick the stream
|
||||
ourselves and prefer `audio/mp4` (AAC — symphonia decodes it) over
|
||||
Opus. Captures get `.m4a` audio the local player can play.
|
||||
|
||||
Robustness note: any non-yt-dlp extractor can break when YouTube
|
||||
changes Innertube. rustypipe is the most actively maintained Rust
|
||||
option, ships `rustypipe-botguard` (an optional *Rust* helper binary,
|
||||
auto-detected on PATH) for PO-token attestation if YouTube starts
|
||||
demanding it, and persists client state in a cache file. Accepted risk,
|
||||
revisit if streams start failing.
|
||||
|
||||
## D1 — Extractor seam
|
||||
|
||||
The subprocess `Engine` is replaced by an `Extractor` trait owned by
|
||||
`ytdy` (search videos, video details, audio stream URL, saved
|
||||
playlists, playlist videos) with two implementations:
|
||||
|
||||
- `RustyPipeExtractor` — the real one, wrapping one `RustyPipe` client.
|
||||
- a test fake — provider logic (path scheme, search-term store, node
|
||||
shapes, gating) is tested without network or fake shell scripts.
|
||||
|
||||
`Client` keeps its public `ProviderClient` surface, path scheme, and
|
||||
the in-memory search-term store unchanged.
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
tui -> server -> ytdy: "/youtube/..."
|
||||
ytdy: {
|
||||
client: "Client\n(paths, search terms, nodes)"
|
||||
extractor: "Extractor trait"
|
||||
client -> extractor
|
||||
}
|
||||
ytdy.extractor -> rustypipe: "RustyPipeExtractor"
|
||||
rustypipe -> youtube: "Innertube (HTTPS)"
|
||||
tests -> ytdy.extractor: "FakeExtractor"
|
||||
```
|
||||
|
||||
## D2 — Stream selection (the playback fix)
|
||||
|
||||
`audio_stream_url` prefers the highest-average-bitrate `audio/mp4`
|
||||
stream (AAC — decodable by the player); only if none exists does it
|
||||
fall back to the overall best audio stream, with a warning (it will
|
||||
likely not decode locally, but the URL is still honest — e.g. a future
|
||||
player may cope). Download captures inherit the same choice, so their
|
||||
audio is `.m4a`.
|
||||
|
||||
## D2-revised — Stream *fetching* (2026-07-21, second round)
|
||||
|
||||
Real use immediately hit YouTube's tokenless-fetch enforcement. Live
|
||||
findings against googlevideo (all reproduced, same day):
|
||||
|
||||
- Every rustypipe (iOS-client) stream URL serves **exactly its leading
|
||||
~1 MiB**, measured to the byte (403 at cumulative 1 048 576): plain
|
||||
GETs 403, open-ended ranges 403, bounded ranges work only until the
|
||||
budget is spent, and a **fresh URL refuses to start at an offset** —
|
||||
so chaining fresh URLs per window is impossible.
|
||||
- PO tokens would lift the cap, but rustypipe attaches them only to
|
||||
web-family clients, whose **signature deciphering is broken upstream
|
||||
right now** ("could not extract sig fn name", also on git master) —
|
||||
`rustypipe-botguard` was built and tested; ineffective through the
|
||||
iOS client (no `pot` parameter).
|
||||
- `yt-dlp` (2026.07.04) still solves the cipher challenges; its URLs
|
||||
accept plain and ranged GETs for the whole file, **throttled to
|
||||
~32 KB/s** — twice the itag-140 audio bitrate, so playback holds and
|
||||
captures are merely slow. Its TV/Android clients are SABR-blocked
|
||||
(no URLs at all), so this is the state of the art everywhere.
|
||||
|
||||
**Decisions:**
|
||||
|
||||
1. **Windowed HTTP fetching everywhere.** The player streams through a
|
||||
`WindowedHttpStream` (audio-player, a `stream-download`
|
||||
`SourceStream`) and the capture `Downloader` downloads in the same
|
||||
bounded ~1 MiB `Range` windows — the request pattern real players
|
||||
produce, correct for every provider (servers that ignore `Range`
|
||||
degrade to one 200 body; rejected windows fail typed, never loop).
|
||||
2. **`yt-dlp` returns as a stream-URL-only sidecar.** All metadata
|
||||
(search, playlists, details) stays on the pure-Rust rustypipe
|
||||
extractor; only `get_urls_for_track` consults `yt-dlp` (argv-only,
|
||||
bounded, stderr-summarized). A missing binary degrades with a
|
||||
warning — streams then stop after their first ~1 MiB instead of
|
||||
failing entirely.
|
||||
3. **The pure-Rust path stays wired.** `botguard_bin` is configurable
|
||||
(and PATH-auto-detected by rustypipe); when upstream deciphering
|
||||
recovers, PO-token'd rustypipe URLs make the sidecar unnecessary
|
||||
without code changes beyond removing the fallback preference.
|
||||
|
||||
The per-track capture deadline was raised to 30 minutes — at 32 KB/s a
|
||||
long track legitimately takes that.
|
||||
|
||||
## D3 — Login and playlists
|
||||
|
||||
The `cookies` setting keeps its meaning: a path to a Netscape
|
||||
`cookies.txt` export. rustypipe consumes it natively
|
||||
(`user_auth_set_cookie_txt`) and — importantly — **persists the rotated
|
||||
cookie in its cache file**, which outlives the (quickly stale) original
|
||||
export. Init order: if the cache already holds a working login
|
||||
(`user_auth_check_cookie`), use it; otherwise load the configured file;
|
||||
on any failure degrade to logged-out with a warning (never a failed
|
||||
init). "Logged in" gates the `playlists` subtree exactly as before,
|
||||
now backed by `saved_playlists()` (the `userdata` feature) instead of a
|
||||
live-unvalidated `feed/playlists` scrape.
|
||||
|
||||
Playlist nodes page through `Paginator::extend_limit` up to a track
|
||||
cap (`MAX_PLAYLIST_TRACKS`, 1000) instead of loading whole playlists
|
||||
blindly.
|
||||
|
||||
## D4 — Configuration and environment
|
||||
|
||||
`ytdy.toml`: `binary` is gone (nothing to spawn); `cookies` and
|
||||
`search_results` stay; `call_timeout_secs` maps to the rustypipe
|
||||
client timeout. New `RustyPipe` client state lives in
|
||||
`<config>/crabidy/rustypipe/` (`storage_dir`) — it holds the rotated
|
||||
auth cookie, so it is as secret as the cookies file; neither its
|
||||
contents nor cookie values are ever logged. `yt-dlp` leaves
|
||||
`devenv.nix`. Init never probes the network except when validating a
|
||||
configured login; a failed login check degrades, a broken client build
|
||||
disables the provider non-fatally (as before).
|
||||
|
||||
## D5 — Out of scope
|
||||
|
||||
- Installing `rustypipe-botguard` (optional PO-token helper); document
|
||||
only. Streams work without it today.
|
||||
- Opus support in the player (a symphonia Opus decoder does not exist;
|
||||
an opus feature via a different rodio decoder is a separate project).
|
||||
- YouTube Music (rustypipe supports it; nothing in crabidy asks yet).
|
||||
|
||||
## Risks
|
||||
|
||||
- Innertube changes can break rustypipe between releases; mitigations:
|
||||
cache-backed client data, optional botguard, active upstream.
|
||||
- `saved_playlists` needs valid cookies; YouTube rotates them — the
|
||||
cache keeps the rotated value, but a long-cold server may need a
|
||||
fresh export. Degrades to logged-out, never fails.
|
||||
|
|
@ -5,7 +5,10 @@ edition.workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
reqwest.workspace = true
|
||||
rodio.workspace = true
|
||||
stream-download.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
mod player;
|
||||
mod player_engine;
|
||||
mod spectrum_tap;
|
||||
pub mod windowed_http;
|
||||
|
||||
pub use player::{Player, PlayerError};
|
||||
pub use player_engine::{MediaInfo, PlayerMessage};
|
||||
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
|
||||
|
|
|
|||
|
|
@ -5,13 +5,19 @@ use anyhow::Result;
|
|||
use flume::{Receiver, Sender};
|
||||
use tracing::error;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
||||
use crate::spectrum_tap::SpectrumTap;
|
||||
|
||||
pub enum PlayerError {}
|
||||
|
||||
pub struct Player {
|
||||
pub messages: Receiver<PlayerMessage>,
|
||||
tx_engine: Sender<PlayerEngineCommand>,
|
||||
/// The spectrum tap, shared with the engine thread. The server's FFT
|
||||
/// task reads it (architecture/spectrum.md).
|
||||
spectrum: Arc<SpectrumTap>,
|
||||
}
|
||||
|
||||
impl Default for Player {
|
||||
|
|
@ -25,8 +31,12 @@ impl Default for Player {
|
|||
// tokio context but needs one to create http streams.
|
||||
let runtime = tokio::runtime::Handle::try_current().ok();
|
||||
|
||||
// Created here and shared into the engine thread so callers can
|
||||
// read it without reaching across the thread boundary.
|
||||
let spectrum = SpectrumTap::new();
|
||||
let engine_tap = spectrum.clone();
|
||||
thread::spawn(move || {
|
||||
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) {
|
||||
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) {
|
||||
Err(e) => {
|
||||
error!("Could not initialize player: {}", e);
|
||||
return;
|
||||
|
|
@ -39,11 +49,17 @@ impl Default for Player {
|
|||
Self {
|
||||
messages,
|
||||
tx_engine,
|
||||
spectrum,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// The spectrum tap the played audio is mirrored into.
|
||||
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
|
||||
self.spectrum.clone()
|
||||
}
|
||||
|
||||
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
|
|
@ -108,6 +124,15 @@ impl Player {
|
|||
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<()> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
|
|
|
|||
|
|
@ -10,12 +10,28 @@ use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
|
|||
use rodio::{Decoder, Source};
|
||||
use stream_download::storage::temp::TempStorageProvider;
|
||||
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 tracing::{debug, info, instrument, trace, warn};
|
||||
use url::Url;
|
||||
|
||||
/// How long we wait for the initial prefetch of a network stream.
|
||||
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// What playback logs for a source: local paths verbatim, network URLs
|
||||
/// reduced to scheme and host — stream URLs embed access tokens
|
||||
/// (googlevideo `sig`, tidal tokens) and must never reach the log.
|
||||
fn display_source(source_str: &str) -> String {
|
||||
match Url::parse(source_str) {
|
||||
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
||||
format!("{}://{}/…", url.scheme(), url.host_str().unwrap_or("?"))
|
||||
}
|
||||
_ => source_str.to_string(),
|
||||
}
|
||||
}
|
||||
/// Interval between elapsed-position updates while playing.
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
|
|
@ -31,6 +47,7 @@ pub enum PlayerEngineCommand {
|
|||
GetElapsed(Sender<Result<Duration>>),
|
||||
SeekTo(Duration, Sender<Result<Duration>>),
|
||||
GetVolume(Sender<f32>),
|
||||
ToggleMute(Sender<bool>),
|
||||
GetPaused(Sender<Result<bool>>),
|
||||
/// End of stream for the source started by the given generation.
|
||||
/// Stale generations are ignored so an old track finishing can never
|
||||
|
|
@ -78,6 +95,16 @@ pub struct PlayerEngine {
|
|||
// Present when the engine had to bring its own runtime because the
|
||||
// creating thread was not inside a tokio context.
|
||||
_owned_runtime: Option<tokio::runtime::Runtime>,
|
||||
/// Shared client for windowed network streams.
|
||||
http: reqwest::Client,
|
||||
/// Ring the played audio is mirrored into for the spectrum
|
||||
/// visualizer (architecture/spectrum.md). Handed out via
|
||||
/// [`Self::spectrum_tap`] so the server's FFT task can read it.
|
||||
spectrum: Arc<SpectrumTap>,
|
||||
/// Whether output is muted (sink volume zeroed).
|
||||
muted: bool,
|
||||
/// Volume to restore on unmute.
|
||||
pre_mute_volume: f32,
|
||||
}
|
||||
|
||||
impl PlayerEngine {
|
||||
|
|
@ -85,6 +112,7 @@ impl PlayerEngine {
|
|||
tx_engine: Sender<PlayerEngineCommand>,
|
||||
tx_player: Sender<PlayerMessage>,
|
||||
runtime: Option<tokio::runtime::Handle>,
|
||||
spectrum: Arc<SpectrumTap>,
|
||||
) -> Result<Self> {
|
||||
let stream =
|
||||
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
||||
|
|
@ -101,6 +129,10 @@ impl PlayerEngine {
|
|||
}
|
||||
};
|
||||
info!("audio output device opened");
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.context("failed to build the http client")?;
|
||||
Ok(Self {
|
||||
current_source: None,
|
||||
media_info: None,
|
||||
|
|
@ -111,6 +143,10 @@ impl PlayerEngine {
|
|||
tx_player,
|
||||
runtime,
|
||||
_owned_runtime: owned_runtime,
|
||||
http,
|
||||
spectrum,
|
||||
muted: false,
|
||||
pre_mute_volume: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +181,7 @@ impl PlayerEngine {
|
|||
send_reply(tx, self.set_volume(volume));
|
||||
}
|
||||
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::Eos(generation) => self.handle_eos(generation),
|
||||
}
|
||||
|
|
@ -167,7 +204,7 @@ impl PlayerEngine {
|
|||
});
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
#[instrument(skip_all, fields(source = %display_source(source_str)))]
|
||||
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
||||
self.reset();
|
||||
|
||||
|
|
@ -194,12 +231,19 @@ impl PlayerEngine {
|
|||
self.generation += 1;
|
||||
let duration = match Url::parse(source_str) {
|
||||
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
||||
trace!(%url, "opening network stream");
|
||||
trace!(
|
||||
host = url.host_str().unwrap_or("?"),
|
||||
"opening network stream"
|
||||
);
|
||||
// Windowed fetching: some CDNs (googlevideo) reject plain
|
||||
// and open-ended requests with 403 and only serve bounded
|
||||
// ranges (see audio-player/src/windowed_http.rs).
|
||||
let params = WindowedHttpParams::new(url.clone(), self.http.clone());
|
||||
let reader = self.runtime.block_on(async {
|
||||
tokio::time::timeout(
|
||||
STREAM_OPEN_TIMEOUT,
|
||||
StreamDownload::new_http(
|
||||
url.clone(),
|
||||
StreamDownload::new::<WindowedHttpStream>(
|
||||
params,
|
||||
TempStorageProvider::new(),
|
||||
Settings::default(),
|
||||
),
|
||||
|
|
@ -208,14 +252,25 @@ impl PlayerEngine {
|
|||
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
|
||||
.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);
|
||||
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())
|
||||
{
|
||||
builder = builder.with_hint(extension);
|
||||
}
|
||||
let decoder = builder.build().context("failed to decode http stream")?;
|
||||
let duration = decoder.total_duration();
|
||||
self.sink.append(decoder);
|
||||
// Mirror the played audio into the spectrum tap (it only
|
||||
// observes; playback is unaffected).
|
||||
self.sink
|
||||
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
||||
duration
|
||||
}
|
||||
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
||||
|
|
@ -236,7 +291,8 @@ impl PlayerEngine {
|
|||
}
|
||||
let decoder = builder.build().context("failed to decode file")?;
|
||||
let duration = decoder.total_duration();
|
||||
self.sink.append(decoder);
|
||||
self.sink
|
||||
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
||||
duration
|
||||
}
|
||||
};
|
||||
|
|
@ -338,13 +394,37 @@ impl PlayerEngine {
|
|||
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 {
|
||||
if self.muted {
|
||||
self.pre_mute_volume
|
||||
} else {
|
||||
self.sink.volume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the volume and unmutes: reaching for the volume is an intent
|
||||
/// to hear something.
|
||||
pub fn set_volume(&mut self, volume: f32) -> f32 {
|
||||
self.muted = false;
|
||||
self.sink.set_volume(volume.clamp(0.0, 1.1));
|
||||
self.sink.volume()
|
||||
}
|
||||
|
||||
pub fn set_volume(&mut self, volume: f32) -> f32 {
|
||||
self.sink.set_volume(volume.clamp(0.0, 1.1));
|
||||
self.sink.volume()
|
||||
/// Toggles mute by zeroing the sink volume and remembering the level
|
||||
/// to restore. Returns the new muted state.
|
||||
pub fn toggle_mute(&mut self) -> bool {
|
||||
if self.muted {
|
||||
self.sink.set_volume(self.pre_mute_volume);
|
||||
self.muted = false;
|
||||
} else {
|
||||
self.pre_mute_volume = self.sink.volume();
|
||||
self.sink.set_volume(0.0);
|
||||
self.muted = true;
|
||||
}
|
||||
self.muted
|
||||
}
|
||||
|
||||
fn handle_eos(&mut self, generation: u64) {
|
||||
|
|
@ -380,3 +460,22 @@ fn send_reply<T>(tx: Sender<T>, value: T) {
|
|||
warn!("player engine reply receiver dropped");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn logged_sources_never_carry_url_tokens() {
|
||||
// Stream URLs embed access tokens; only scheme and host may be
|
||||
// logged. Local paths pass through verbatim.
|
||||
assert_eq!(
|
||||
display_source("https://rr1.googlevideo.com/videoplayback?sig=SECRET&x=1"),
|
||||
"https://rr1.googlevideo.com/…"
|
||||
);
|
||||
assert_eq!(
|
||||
display_source("/home/user/music/song.m4a"),
|
||||
"/home/user/music/song.m4a"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
//! A near-zero-cost tap on the audio the player is playing, feeding the
|
||||
//! frequency-spectrum visualizer (architecture/spectrum.md).
|
||||
//!
|
||||
//! [`TappingSource`] wraps the decoded rodio source and, as the mixer
|
||||
//! pulls samples on the audio thread, copies each frame (downmixed to
|
||||
//! mono) into a fixed lock-free ring, [`SpectrumTap`]. The server's FFT
|
||||
//! task reads snapshots of that ring off the audio thread. The tap only
|
||||
//! *observes*: it never blocks, allocates, or logs on the audio path,
|
||||
//! and benign read/write races are acceptable for a visualizer.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::SeekError;
|
||||
use rodio::{ChannelCount, Sample, SampleRate, Source};
|
||||
|
||||
/// Ring length in mono frames — BeSpec's 2048-point FFT window.
|
||||
pub const SPECTRUM_WINDOW: usize = 2048;
|
||||
|
||||
/// A fixed ring of the most recent mono samples plus a monotonically
|
||||
/// increasing frame counter. Single-producer (the audio thread via
|
||||
/// [`TappingSource`]) / multi-consumer (the FFT task). Samples are
|
||||
/// stored as `f32` bit patterns in `AtomicU32`; races are benign.
|
||||
pub struct SpectrumTap {
|
||||
ring: Box<[AtomicU32]>,
|
||||
/// Total frames ever written; `% SPECTRUM_WINDOW` is the next slot,
|
||||
/// and the value doubles as the idle-detection counter.
|
||||
written: AtomicU64,
|
||||
}
|
||||
|
||||
impl SpectrumTap {
|
||||
pub fn new() -> Arc<Self> {
|
||||
let ring = (0..SPECTRUM_WINDOW)
|
||||
.map(|_| AtomicU32::new(0))
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice();
|
||||
Arc::new(Self {
|
||||
ring,
|
||||
written: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Audio thread: record one mono frame. One relaxed store plus a
|
||||
/// counter bump — nothing else.
|
||||
fn push(&self, sample: f32) {
|
||||
let n = self.written.fetch_add(1, Ordering::Relaxed);
|
||||
let slot = (n as usize) % SPECTRUM_WINDOW;
|
||||
self.ring[slot].store(sample.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Frames written so far. The FFT task diffs this between ticks to
|
||||
/// tell "audio flowing" from "idle" without touching the player's
|
||||
/// command channel (architecture/spectrum.md D2).
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.written.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A chronological snapshot (oldest first) of the ring, for the FFT.
|
||||
/// May momentarily mix samples from an in-flight write — harmless
|
||||
/// for visualization.
|
||||
pub fn snapshot(&self) -> Vec<f32> {
|
||||
let written = self.written.load(Ordering::Relaxed) as usize;
|
||||
(0..SPECTRUM_WINDOW)
|
||||
.map(|k| {
|
||||
let idx = written.wrapping_add(k) % SPECTRUM_WINDOW;
|
||||
f32::from_bits(self.ring[idx].load(Ordering::Relaxed))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a rodio source, mirroring each played frame into a
|
||||
/// [`SpectrumTap`]. Every `Source`/`Iterator` method delegates to the
|
||||
/// inner source unchanged (including `try_seek`, so track seeking keeps
|
||||
/// working) — the only addition is the per-frame mono downmix pushed to
|
||||
/// the tap.
|
||||
pub struct TappingSource<S> {
|
||||
inner: S,
|
||||
tap: Arc<SpectrumTap>,
|
||||
/// Channels of the current span; re-read at each frame boundary so a
|
||||
/// mid-stream channel change cannot desync the downmix.
|
||||
channels: u16,
|
||||
channel_index: u16,
|
||||
frame_sum: f32,
|
||||
}
|
||||
|
||||
impl<S: Source> TappingSource<S> {
|
||||
pub fn new(inner: S, tap: Arc<SpectrumTap>) -> Self {
|
||||
let channels = inner.channels().get();
|
||||
Self {
|
||||
inner,
|
||||
tap,
|
||||
channels,
|
||||
channel_index: 0,
|
||||
frame_sum: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source> Iterator for TappingSource<S> {
|
||||
type Item = Sample;
|
||||
|
||||
fn next(&mut self) -> Option<Sample> {
|
||||
let sample = self.inner.next()?;
|
||||
self.frame_sum += sample;
|
||||
self.channel_index += 1;
|
||||
if self.channel_index >= self.channels {
|
||||
let mono = self.frame_sum / f32::from(self.channels.max(1));
|
||||
self.tap.push(mono);
|
||||
self.frame_sum = 0.0;
|
||||
self.channel_index = 0;
|
||||
// Track channel-count changes between spans.
|
||||
self.channels = self.inner.channels().get();
|
||||
}
|
||||
Some(sample)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source> Source for TappingSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.current_span_len()
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
self.inner.channels()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
self.inner.sample_rate()
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.inner.total_duration()
|
||||
}
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
|
||||
#[test]
|
||||
fn snapshot_returns_the_window_oldest_first() {
|
||||
let tap = SpectrumTap::new();
|
||||
// Write one full window plus a bit, so the ring has wrapped.
|
||||
for i in 0..(SPECTRUM_WINDOW + 3) {
|
||||
tap.push(i as f32);
|
||||
}
|
||||
let snap = tap.snapshot();
|
||||
assert_eq!(snap.len(), SPECTRUM_WINDOW);
|
||||
// Oldest retained frame is (total - window); newest is total-1.
|
||||
let total = (SPECTRUM_WINDOW + 3) as f32;
|
||||
assert_eq!(*snap.first().unwrap(), total - SPECTRUM_WINDOW as f32);
|
||||
assert_eq!(*snap.last().unwrap(), total - 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapping_source_downmixes_and_passes_samples_through() {
|
||||
// Stereo: [L,R, L,R] = frames (1,3) and (5,7) → mono 2 and 6.
|
||||
let tap = SpectrumTap::new();
|
||||
let buf = SamplesBuffer::new(
|
||||
ChannelCount::new(2).unwrap(),
|
||||
SampleRate::new(44_100).unwrap(),
|
||||
vec![1.0f32, 3.0, 5.0, 7.0],
|
||||
);
|
||||
let tapped: Vec<f32> = TappingSource::new(buf, tap.clone()).collect();
|
||||
// Playback is untouched: every sample passes through verbatim.
|
||||
assert_eq!(tapped, vec![1.0, 3.0, 5.0, 7.0]);
|
||||
// Two mono frames were tapped.
|
||||
assert_eq!(tap.frame_count(), 2);
|
||||
let snap = tap.snapshot();
|
||||
assert_eq!(snap[SPECTRUM_WINDOW - 2], 2.0);
|
||||
assert_eq!(snap[SPECTRUM_WINDOW - 1], 6.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,547 @@
|
|||
//! A windowed HTTP [`SourceStream`]: fetches media in small **bounded**
|
||||
//! `Range` requests instead of one open-ended GET.
|
||||
//!
|
||||
//! Some CDNs (notably googlevideo, see
|
||||
//! `architecture/youtube-rustypipe.md`) reject plain and open-ended
|
||||
//! requests from unattested clients with `403 Forbidden` and only serve
|
||||
//! bounded ranges of about a megabyte — the request pattern real players
|
||||
//! produce. This stream chains such windows transparently; servers that
|
||||
//! ignore the `Range` header (plain `200`) degrade to one continuous
|
||||
//! body without windowing (and without seek support).
|
||||
//!
|
||||
//! Error messages never include the URL — stream URLs may embed tokens.
|
||||
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::{Future, Stream};
|
||||
use stream_download::source::{DecodeError, SourceStream};
|
||||
use tracing::{debug, trace, warn};
|
||||
use url::Url;
|
||||
|
||||
/// Bytes per request window. Verified against googlevideo: 1 MiB windows
|
||||
/// are served, 8 MiB and open-ended requests are rejected.
|
||||
pub const WINDOW_SIZE: u64 = 1024 * 1024;
|
||||
|
||||
/// Parameters for [`WindowedHttpStream::create`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WindowedHttpParams {
|
||||
pub url: Url,
|
||||
pub client: reqwest::Client,
|
||||
/// Window size in bytes; [`WINDOW_SIZE`] outside of tests.
|
||||
pub window: u64,
|
||||
}
|
||||
|
||||
impl WindowedHttpParams {
|
||||
pub fn new(url: Url, client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
url,
|
||||
client,
|
||||
window: WINDOW_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error creating the stream (first window request failed).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct WindowedHttpError(String);
|
||||
|
||||
impl DecodeError for WindowedHttpError {}
|
||||
|
||||
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
|
||||
type WindowFuture = Pin<Box<dyn Future<Output = io::Result<Window>> + Send + Sync>>;
|
||||
|
||||
/// One server response being drained.
|
||||
struct Window {
|
||||
stream: BytesStream,
|
||||
/// Absolute end (exclusive) of the bytes this response carries;
|
||||
/// `u64::MAX` for an un-ranged whole-body response.
|
||||
end: u64,
|
||||
/// Total resource size, when the response revealed it.
|
||||
total: Option<u64>,
|
||||
/// The server honored the `Range` header (`206`).
|
||||
ranged: bool,
|
||||
}
|
||||
|
||||
enum State {
|
||||
/// Draining the current response.
|
||||
Streaming {
|
||||
stream: BytesStream,
|
||||
end: u64,
|
||||
},
|
||||
/// Waiting for the next window request.
|
||||
Requesting(WindowFuture),
|
||||
Finished,
|
||||
}
|
||||
|
||||
/// See the module docs.
|
||||
pub struct WindowedHttpStream {
|
||||
// (Debug impl below — the state machine holds unnameable futures.)
|
||||
client: reqwest::Client,
|
||||
url: Url,
|
||||
window: u64,
|
||||
/// Total resource size (from `Content-Range`); `None` when unknown.
|
||||
content_length: Option<u64>,
|
||||
/// The server honors ranges — windowing and seeking are available.
|
||||
ranged: bool,
|
||||
/// Absolute offset of the next byte to hand out.
|
||||
position: u64,
|
||||
/// Exclusive end requested via `seek_range`; `None` = to the end.
|
||||
limit: Option<u64>,
|
||||
/// Bytes to silently drop before yielding (un-ranged reconnect
|
||||
/// catch-up).
|
||||
discard: u64,
|
||||
state: State,
|
||||
}
|
||||
|
||||
/// Requests one bounded window `[start, end_exclusive)`.
|
||||
///
|
||||
/// `206` yields a ranged window (end and total parsed from
|
||||
/// `Content-Range`, falling back to `Content-Length`); `200` means the
|
||||
/// server ignored the header and sent the whole body; `416` past the end
|
||||
/// yields an empty terminal window. Anything else is an error carrying
|
||||
/// the status only.
|
||||
async fn request_window(
|
||||
client: reqwest::Client,
|
||||
url: Url,
|
||||
start: u64,
|
||||
end_exclusive: u64,
|
||||
) -> io::Result<Window> {
|
||||
let range = format!("bytes={start}-{}", end_exclusive.saturating_sub(1));
|
||||
trace!(range, "requesting window");
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, range)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| io::Error::other(format!("window request failed: {}", err.without_url())))?;
|
||||
match response.status() {
|
||||
reqwest::StatusCode::PARTIAL_CONTENT => {
|
||||
// `Content-Range: bytes <start>-<end>/<total|*>`
|
||||
let content_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let (end, total) = match content_range.as_deref().and_then(parse_content_range) {
|
||||
Some((_, range_end, total)) => (range_end + 1, total),
|
||||
None => {
|
||||
// No usable Content-Range: derive the window end from
|
||||
// the body length.
|
||||
let len = response.content_length().unwrap_or(0);
|
||||
(start + len, None)
|
||||
}
|
||||
};
|
||||
Ok(Window {
|
||||
stream: Box::pin(response.bytes_stream()),
|
||||
end,
|
||||
total,
|
||||
ranged: true,
|
||||
})
|
||||
}
|
||||
reqwest::StatusCode::OK => {
|
||||
let total = response.content_length();
|
||||
Ok(Window {
|
||||
stream: Box::pin(response.bytes_stream()),
|
||||
end: u64::MAX,
|
||||
total,
|
||||
ranged: false,
|
||||
})
|
||||
}
|
||||
reqwest::StatusCode::RANGE_NOT_SATISFIABLE => Ok(Window {
|
||||
stream: Box::pin(futures::stream::empty()),
|
||||
end: start,
|
||||
total: None,
|
||||
ranged: true,
|
||||
}),
|
||||
status => Err(io::Error::other(format!(
|
||||
"window request rejected: {status}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
|
||||
fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
|
||||
let rest = value.trim().strip_prefix("bytes ")?;
|
||||
let (range, total) = rest.split_once('/')?;
|
||||
let (start, end) = range.split_once('-')?;
|
||||
let total = match total.trim() {
|
||||
"*" => None,
|
||||
n => Some(n.parse().ok()?),
|
||||
};
|
||||
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WindowedHttpStream {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WindowedHttpStream")
|
||||
.field("content_length", &self.content_length)
|
||||
.field("ranged", &self.ranged)
|
||||
.field("position", &self.position)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowedHttpStream {
|
||||
/// The exclusive end the consumer currently wants: the seek limit
|
||||
/// clamped to the known size.
|
||||
fn effective_end(&self) -> Option<u64> {
|
||||
match (self.limit, self.content_length) {
|
||||
(Some(limit), Some(len)) => Some(limit.min(len)),
|
||||
(Some(limit), None) => Some(limit),
|
||||
(None, len) => len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules the request for the window starting at `start`, or
|
||||
/// finishes when nothing is left to fetch.
|
||||
fn schedule_window(&mut self, start: u64) {
|
||||
let end = match self.window_end(start) {
|
||||
Some(end) => end,
|
||||
None => {
|
||||
self.state = State::Finished;
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.state = State::Requesting(Box::pin(request_window(
|
||||
self.client.clone(),
|
||||
self.url.clone(),
|
||||
start,
|
||||
end,
|
||||
)));
|
||||
}
|
||||
|
||||
/// The exclusive end of the window starting at `start`; `None` when
|
||||
/// nothing is left to fetch.
|
||||
fn window_end(&self, start: u64) -> Option<u64> {
|
||||
match self.effective_end() {
|
||||
Some(effective) if start >= effective => None,
|
||||
Some(effective) => Some((start + self.window).min(effective)),
|
||||
None => Some(start + self.window),
|
||||
}
|
||||
}
|
||||
|
||||
/// Eagerly opens the window at `start` (seeks and reconnects), so
|
||||
/// request failures surface to the caller instead of re-arising on
|
||||
/// every poll.
|
||||
async fn open_window(&mut self, start: u64) -> io::Result<()> {
|
||||
match self.window_end(start) {
|
||||
None => {
|
||||
self.state = State::Finished;
|
||||
Ok(())
|
||||
}
|
||||
Some(end) => {
|
||||
let window =
|
||||
request_window(self.client.clone(), self.url.clone(), start, end).await?;
|
||||
self.state = State::Streaming {
|
||||
stream: window.stream,
|
||||
end: window.end,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for WindowedHttpStream {
|
||||
type Item = io::Result<Bytes>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
loop {
|
||||
match &mut this.state {
|
||||
State::Finished => return Poll::Ready(None),
|
||||
State::Requesting(future) => match future.as_mut().poll(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => {
|
||||
this.state = State::Finished;
|
||||
return Poll::Ready(Some(Err(err)));
|
||||
}
|
||||
Poll::Ready(Ok(window)) => {
|
||||
this.state = State::Streaming {
|
||||
stream: window.stream,
|
||||
end: window.end,
|
||||
};
|
||||
}
|
||||
},
|
||||
State::Streaming { stream, end } => match stream.as_mut().poll_next(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Some(Err(err))) => {
|
||||
return Poll::Ready(Some(Err(io::Error::other(format!(
|
||||
"stream body failed: {}",
|
||||
err.without_url()
|
||||
)))));
|
||||
}
|
||||
Poll::Ready(Some(Ok(mut bytes))) => {
|
||||
// Un-ranged reconnect catch-up: drop the prefix the
|
||||
// consumer already has.
|
||||
if this.discard > 0 {
|
||||
let drop_now = this.discard.min(bytes.len() as u64) as usize;
|
||||
let _ = bytes.split_to(drop_now);
|
||||
this.discard -= drop_now as u64;
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this.position += bytes.len() as u64;
|
||||
return Poll::Ready(Some(Ok(bytes)));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
let window_end = *end;
|
||||
if !this.ranged {
|
||||
// One whole-body response: its end is the end.
|
||||
this.state = State::Finished;
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
if let Some(effective) = this.effective_end() {
|
||||
if this.position >= effective {
|
||||
this.state = State::Finished;
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
}
|
||||
if this.position < window_end && this.content_length.is_none() {
|
||||
// A short window with no known total: the
|
||||
// resource ended early.
|
||||
this.state = State::Finished;
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
let next = this.position;
|
||||
this.schedule_window(next);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceStream for WindowedHttpStream {
|
||||
type Params = WindowedHttpParams;
|
||||
type StreamCreationError = WindowedHttpError;
|
||||
|
||||
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
|
||||
let window = request_window(params.client.clone(), params.url.clone(), 0, params.window)
|
||||
.await
|
||||
.map_err(|err| WindowedHttpError(err.to_string()))?;
|
||||
let content_length = window.total;
|
||||
let ranged = window.ranged;
|
||||
debug!(?content_length, ranged, "windowed http stream open");
|
||||
Ok(Self {
|
||||
client: params.client,
|
||||
url: params.url,
|
||||
window: params.window,
|
||||
content_length,
|
||||
ranged,
|
||||
position: 0,
|
||||
limit: None,
|
||||
discard: 0,
|
||||
state: State::Streaming {
|
||||
stream: window.stream,
|
||||
end: window.end,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn content_length(&self) -> Option<u64> {
|
||||
self.content_length
|
||||
}
|
||||
|
||||
async fn seek_range(&mut self, start: u64, end: Option<u64>) -> io::Result<()> {
|
||||
trace!(start, ?end, "seek");
|
||||
self.position = start;
|
||||
self.limit = end;
|
||||
self.discard = 0;
|
||||
// Eager: the request happens *here*, so a rejected window is an
|
||||
// error the retry logic can time out on — a lazily scheduled
|
||||
// request that keeps failing would look like a successful
|
||||
// reconnect every time and retry forever.
|
||||
self.open_window(start).await
|
||||
}
|
||||
|
||||
async fn reconnect(&mut self, current_position: u64) -> io::Result<()> {
|
||||
if self.ranged {
|
||||
self.position = current_position;
|
||||
self.discard = 0;
|
||||
return self.open_window(current_position).await;
|
||||
}
|
||||
// The server does not honor ranges: refetch from the start and
|
||||
// drop what the consumer already has.
|
||||
warn!(
|
||||
current_position,
|
||||
"reconnecting to a server without range support"
|
||||
);
|
||||
self.position = current_position;
|
||||
self.discard = current_position;
|
||||
let window = request_window(self.client.clone(), self.url.clone(), 0, u64::MAX).await?;
|
||||
self.state = State::Streaming {
|
||||
stream: window.stream,
|
||||
end: window.end,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supports_seek(&self) -> bool {
|
||||
self.ranged
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::StreamExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A minimal HTTP server for one fixed body. With `ranged`, bounded
|
||||
/// `Range` requests get `206` + `Content-Range` slices — and, like
|
||||
/// googlevideo, open-ended or oversized ranges get `403`. Without,
|
||||
/// every request gets the whole body as `200`.
|
||||
async fn serve(body: Vec<u8>, ranged: bool, max_window: u64) -> Url {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf).to_lowercase();
|
||||
let range = request
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||
.and_then(|spec| {
|
||||
let (start, end) = spec.trim().split_once('-')?;
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let end: Option<u64> = end.parse().ok();
|
||||
Some((start, end))
|
||||
});
|
||||
let total = body.len() as u64;
|
||||
let response = match (ranged, range) {
|
||||
(true, Some((start, Some(end))))
|
||||
if start < total && end - start < max_window =>
|
||||
{
|
||||
let end = end.min(total - 1);
|
||||
let slice = &body[start as usize..=end as usize];
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {start}-{end}/{total}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
slice.len()
|
||||
)
|
||||
.into_bytes();
|
||||
head.extend_from_slice(slice);
|
||||
head
|
||||
}
|
||||
(true, Some((start, _))) if start >= total => {
|
||||
format!("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{total}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
.into_bytes()
|
||||
}
|
||||
(true, _) => {
|
||||
// Open-ended or oversized: rejected, like
|
||||
// googlevideo without a PO token.
|
||||
b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
|
||||
}
|
||||
(false, _) => {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
head.extend_from_slice(&body);
|
||||
head
|
||||
}
|
||||
};
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Url::parse(&format!("http://{addr}/stream")).expect("url")
|
||||
}
|
||||
|
||||
fn params(url: Url, window: u64) -> WindowedHttpParams {
|
||||
WindowedHttpParams {
|
||||
url,
|
||||
client: reqwest::Client::new(),
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_all(stream: &mut WindowedHttpStream) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
out.extend_from_slice(&chunk.expect("chunk"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chains_windows_over_a_range_only_server() {
|
||||
// 10 windows of 16 bytes against a server that 403s anything
|
||||
// bigger — exactly the googlevideo behavior.
|
||||
let body: Vec<u8> = (0..160u32).map(|i| i as u8).collect();
|
||||
let url = serve(body.clone(), true, 64).await;
|
||||
let mut stream = WindowedHttpStream::create(params(url, 16))
|
||||
.await
|
||||
.expect("create");
|
||||
assert_eq!(stream.content_length(), Some(160));
|
||||
assert!(stream.supports_seek());
|
||||
assert_eq!(read_all(&mut stream).await, body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeks_restart_the_window_chain() {
|
||||
let body: Vec<u8> = (0..160u32).map(|i| i as u8).collect();
|
||||
let url = serve(body.clone(), true, 64).await;
|
||||
let mut stream = WindowedHttpStream::create(params(url, 16))
|
||||
.await
|
||||
.expect("create");
|
||||
stream.seek_range(100, None).await.expect("seek");
|
||||
assert_eq!(read_all(&mut stream).await, body[100..]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plain_servers_stream_one_body_without_seek() {
|
||||
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
||||
let url = serve(body.clone(), false, 0).await;
|
||||
let mut stream = WindowedHttpStream::create(params(url, 16))
|
||||
.await
|
||||
.expect("create");
|
||||
assert_eq!(stream.content_length(), Some(100));
|
||||
assert!(!stream.supports_seek());
|
||||
assert_eq!(read_all(&mut stream).await, body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejections_surface_as_errors_without_the_url() {
|
||||
// A ranged server with max_window 0 rejects everything.
|
||||
let url = serve(vec![0; 10], true, 0).await;
|
||||
let err = WindowedHttpStream::create(params(url, 16))
|
||||
.await
|
||||
.expect_err("403 fails creation");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("403"), "{message}");
|
||||
assert!(!message.contains("127.0.0.1"), "no url: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_range_parses_totals_and_wildcards() {
|
||||
assert_eq!(
|
||||
parse_content_range("bytes 0-1023/7831134"),
|
||||
Some((0, 1023, Some(7831134)))
|
||||
);
|
||||
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, 9, None)));
|
||||
assert_eq!(parse_content_range("garbage"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ version.workspace = true
|
|||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
crabidy-core.workspace = true
|
||||
crossterm.workspace = true
|
||||
dirs.workspace = true
|
||||
|
|
@ -13,7 +14,7 @@ ratatui.workspace = true
|
|||
serde.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-stream.workspace = true
|
||||
tonic.workspace = true
|
||||
tonic = { workspace = true, features = ["channel", "codegen"] }
|
||||
tracing.workspace = true
|
||||
tracing-appender.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,851 @@
|
|||
//! Declarative keyboard bindings: the single source of truth for both key
|
||||
//! dispatch (`lookup`) and the help modal (which renders [`BINDINGS`]).
|
||||
//!
|
||||
//! Pure data and pure functions — no I/O, no ratatui types — so the whole
|
||||
//! table is unit-testable without a terminal.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::UiFocus;
|
||||
|
||||
/// Where a binding applies.
|
||||
///
|
||||
/// `Global` entries match regardless of which pane is focused. `Library` and
|
||||
/// `Queue` entries match only while that pane is focused. `Help` entries
|
||||
/// match only while the help modal is open — while it is open, *no* other
|
||||
/// scope matches (the modal is strictly modal).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Scope {
|
||||
Global,
|
||||
Library,
|
||||
Queue,
|
||||
Help,
|
||||
}
|
||||
|
||||
/// Every user-visible operation a key press can trigger.
|
||||
///
|
||||
/// Pane-specific variants are prefixed with their pane so the enum stays
|
||||
/// collision-free as panes grow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
// Global
|
||||
/// Open the help modal (closing it is `CloseHelp`, scope `Help`).
|
||||
OpenHelp,
|
||||
Quit,
|
||||
CycleFocus,
|
||||
TogglePlay,
|
||||
RestartTrack,
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
ToggleMute,
|
||||
ToggleShuffle,
|
||||
ToggleRepeat,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
// Library pane
|
||||
LibraryFirst,
|
||||
LibraryLast,
|
||||
LibraryNext,
|
||||
LibraryPrev,
|
||||
LibraryJumpDown,
|
||||
LibraryJumpUp,
|
||||
LibraryAscend,
|
||||
LibraryDive,
|
||||
LibraryQueueNext,
|
||||
LibraryQueueAppend,
|
||||
LibraryQueueReplace,
|
||||
LibraryToggleMark,
|
||||
/// Open the input overlay to create a child of the currently open
|
||||
/// library node. No-op unless that node `is_creatable` (e.g.
|
||||
/// /tidal/search). While the overlay is open, keys bypass this table
|
||||
/// entirely (see `App::handle_input_key`).
|
||||
LibraryCreateNode,
|
||||
/// Open the input overlay prefilled with the selected item's title to
|
||||
/// rename it. No-op unless the selection `is_editable` (e.g. a search
|
||||
/// term, whose rename re-runs the search).
|
||||
LibraryEditNode,
|
||||
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
||||
/// Cheap deletables (search terms, bookmarks, saved queues) delete
|
||||
/// unconfirmed (architecture/node-editing.md, D4); captures hold
|
||||
/// downloaded audio and open a y/N confirmation instead
|
||||
/// (architecture/capture-deletion.md).
|
||||
LibraryDeleteNode,
|
||||
/// Open the input overlay (prefilled with the selection's title) to
|
||||
/// capture the selected queueable subtree as a bookmark under
|
||||
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
|
||||
LibraryCaptureNode,
|
||||
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
||||
/// every track's audio into `/captures`. No-op unless the bare
|
||||
/// selection is queueable *and* downloadable (e.g. Tidal subtrees).
|
||||
LibraryDownloadNode,
|
||||
/// Open the `/` search input for the focused pane. Typing filters the
|
||||
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
||||
/// filter, `Esc` clears it. Bound in both the library and queue
|
||||
/// scopes; the dispatch targets whichever pane has focus.
|
||||
OpenSearch,
|
||||
// Queue pane
|
||||
QueueInsertHere,
|
||||
QueueFirst,
|
||||
QueueLast,
|
||||
QueueNext,
|
||||
QueuePrev,
|
||||
QueueJumpDown,
|
||||
QueueJumpUp,
|
||||
QueueSelectCurrent,
|
||||
QueuePlaySelected,
|
||||
QueueRemoveTrack,
|
||||
QueueClearKeepCurrent,
|
||||
QueueClearAll,
|
||||
/// Open the input overlay asking for a name to save the queue under
|
||||
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
||||
QueueSaveAs,
|
||||
/// Open the input overlay to **download-capture** the current queue
|
||||
/// straight into `/captures/<name>` (the queue equivalent of the
|
||||
/// library's `W`), instead of having to save it and then capture the
|
||||
/// saved copy. No-op while the queue is empty.
|
||||
QueueDownloadCapture,
|
||||
// Help modal
|
||||
CloseHelp,
|
||||
}
|
||||
|
||||
/// One key binding: a key chord, the scope it applies in, the action it
|
||||
/// triggers, and the human-readable description the help modal shows.
|
||||
pub struct Binding {
|
||||
pub scope: Scope,
|
||||
pub mods: KeyModifiers,
|
||||
pub code: KeyCode,
|
||||
pub action: Action,
|
||||
/// Shown verbatim in the help modal. Imperative mood, no trailing period.
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// All bindings, in help-modal display order (grouped by scope; `Global`
|
||||
/// first, then `Library`, `Queue`, `Help`).
|
||||
///
|
||||
/// Invariant: within one scope, each `(mods, code)` chord appears at most
|
||||
/// once, except that shifted characters may carry either `SHIFT` or `NONE`
|
||||
/// (terminals disagree); `lookup` must treat those as equal for `Char` codes.
|
||||
pub const BINDINGS: &[Binding] = &[
|
||||
// -- Global ----------------------------------------------------------
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('?'),
|
||||
action: Action::OpenHelp,
|
||||
description: "Show this help",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('q'),
|
||||
action: Action::Quit,
|
||||
description: "Quit",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Tab,
|
||||
action: Action::CycleFocus,
|
||||
description: "Switch between library and queue",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char(' '),
|
||||
action: Action::TogglePlay,
|
||||
description: "Play/pause",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('r'),
|
||||
action: Action::RestartTrack,
|
||||
description: "Restart current track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('K'),
|
||||
action: Action::VolumeUp,
|
||||
description: "Volume up",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('J'),
|
||||
action: Action::VolumeDown,
|
||||
description: "Volume down",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('m'),
|
||||
action: Action::ToggleMute,
|
||||
description: "Toggle mute",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('z'),
|
||||
action: Action::ToggleShuffle,
|
||||
description: "Toggle shuffle",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('x'),
|
||||
action: Action::ToggleRepeat,
|
||||
description: "Toggle repeat",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('n'),
|
||||
action: Action::NextTrack,
|
||||
description: "Next track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('p'),
|
||||
action: Action::PrevTrack,
|
||||
description: "Previous track",
|
||||
},
|
||||
// -- Library ---------------------------------------------------------
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('j'),
|
||||
action: Action::LibraryNext,
|
||||
description: "Select next item",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('k'),
|
||||
action: Action::LibraryPrev,
|
||||
description: "Select previous item",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('g'),
|
||||
action: Action::LibraryFirst,
|
||||
description: "Select first item",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('G'),
|
||||
action: Action::LibraryLast,
|
||||
description: "Select last item",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('d'),
|
||||
action: Action::LibraryJumpDown,
|
||||
description: "Jump 15 items down",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('u'),
|
||||
action: Action::LibraryJumpUp,
|
||||
description: "Jump 15 items up",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('h'),
|
||||
action: Action::LibraryAscend,
|
||||
description: "Go to parent folder",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('l'),
|
||||
action: Action::LibraryDive,
|
||||
description: "Enter selected folder",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('s'),
|
||||
action: Action::LibraryToggleMark,
|
||||
description: "Mark/unmark selection",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('w'),
|
||||
action: Action::LibraryCaptureNode,
|
||||
description: "Save selection as bookmark",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('W'),
|
||||
action: Action::LibraryDownloadNode,
|
||||
description: "Download selection as capture (can take long; same name resumes)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('%'),
|
||||
action: Action::LibraryCreateNode,
|
||||
description: "Create node here (e.g. search term)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('e'),
|
||||
action: Action::LibraryEditNode,
|
||||
description: "Rename selected node (e.g. search term)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('d'),
|
||||
action: Action::LibraryDeleteNode,
|
||||
description: "Delete selection (captures ask y/N, and delete files)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('a'),
|
||||
action: Action::LibraryQueueAppend,
|
||||
description: "Append selection to queue",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('L'),
|
||||
action: Action::LibraryQueueNext,
|
||||
description: "Queue selection after current track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Enter,
|
||||
action: Action::LibraryQueueReplace,
|
||||
description: "Replace queue with selection",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('/'),
|
||||
action: Action::OpenSearch,
|
||||
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
||||
},
|
||||
// -- Queue -----------------------------------------------------------
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('j'),
|
||||
action: Action::QueueNext,
|
||||
description: "Select next track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('k'),
|
||||
action: Action::QueuePrev,
|
||||
description: "Select previous track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('g'),
|
||||
action: Action::QueueFirst,
|
||||
description: "Select first track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('G'),
|
||||
action: Action::QueueLast,
|
||||
description: "Select last track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('d'),
|
||||
action: Action::QueueJumpDown,
|
||||
description: "Jump 15 tracks down",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('u'),
|
||||
action: Action::QueueJumpUp,
|
||||
description: "Jump 15 tracks up",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('o'),
|
||||
action: Action::QueueSelectCurrent,
|
||||
description: "Select the playing track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Enter,
|
||||
action: Action::QueuePlaySelected,
|
||||
description: "Play selected track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('p'),
|
||||
action: Action::QueueInsertHere,
|
||||
description: "Insert library selection after this track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('d'),
|
||||
action: Action::QueueRemoveTrack,
|
||||
description: "Remove selected track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('c'),
|
||||
action: Action::QueueClearKeepCurrent,
|
||||
description: "Clear queue except current track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('C'),
|
||||
action: Action::QueueClearAll,
|
||||
description: "Clear entire queue",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('w'),
|
||||
action: Action::QueueSaveAs,
|
||||
description: "Save queue under a name",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('W'),
|
||||
action: Action::QueueDownloadCapture,
|
||||
description: "Capture the queue (download audio; same name resumes)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('/'),
|
||||
action: Action::OpenSearch,
|
||||
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
||||
},
|
||||
// -- Help modal ------------------------------------------------------
|
||||
Binding {
|
||||
scope: Scope::Help,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('?'),
|
||||
action: Action::CloseHelp,
|
||||
description: "Close help",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Help,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Esc,
|
||||
action: Action::CloseHelp,
|
||||
description: "Close help",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Help,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('q'),
|
||||
action: Action::CloseHelp,
|
||||
description: "Close help",
|
||||
},
|
||||
];
|
||||
|
||||
/// Translate a key event into an action, honoring modality and focus.
|
||||
///
|
||||
/// While `help_open` is true only `Scope::Help` entries can match — every
|
||||
/// other key press is swallowed. Otherwise `Scope::Global` entries match in
|
||||
/// any focus and pane entries match only their own `UiFocus`.
|
||||
///
|
||||
/// For `KeyCode::Char` codes the `SHIFT` modifier is ignored during
|
||||
/// comparison (terminals disagree on whether shifted characters like `?` or
|
||||
/// `G` report `SHIFT`); all other modifiers must match exactly.
|
||||
pub fn lookup(focus: UiFocus, help_open: bool, key: KeyEvent) -> Option<Action> {
|
||||
let scope_matches = |scope: Scope| match (help_open, scope) {
|
||||
(true, s) => s == Scope::Help,
|
||||
(false, Scope::Help) => false,
|
||||
(false, Scope::Global) => true,
|
||||
(false, Scope::Library) => matches!(focus, UiFocus::Library),
|
||||
(false, Scope::Queue) => matches!(focus, UiFocus::Queue),
|
||||
};
|
||||
let chord_matches = |b: &Binding| {
|
||||
if b.code != key.code {
|
||||
return false;
|
||||
}
|
||||
if matches!(b.code, KeyCode::Char(_)) {
|
||||
b.mods.difference(KeyModifiers::SHIFT) == key.modifiers.difference(KeyModifiers::SHIFT)
|
||||
} else {
|
||||
b.mods == key.modifiers
|
||||
}
|
||||
};
|
||||
BINDINGS
|
||||
.iter()
|
||||
.find(|b| scope_matches(b.scope) && chord_matches(b))
|
||||
.map(|b| b.action)
|
||||
}
|
||||
|
||||
/// Human-readable label for a binding's key chord, e.g. `"Space"`,
|
||||
/// `"Ctrl+d"`, `"?"`. Used by the help modal so displayed keys are derived
|
||||
/// from the same data dispatch uses.
|
||||
pub fn key_label(mods: KeyModifiers, code: KeyCode) -> String {
|
||||
let key = match code {
|
||||
KeyCode::Char(' ') => "Space".to_string(),
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Tab => "Tab".to_string(),
|
||||
KeyCode::Enter => "Enter".to_string(),
|
||||
KeyCode::Esc => "Esc".to_string(),
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
if mods.contains(KeyModifiers::CONTROL) {
|
||||
format!("Ctrl+{key}")
|
||||
} else {
|
||||
// SHIFT is already visible in the character itself ('K', '?', …).
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, mods)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_bindings_match_in_any_focus() {
|
||||
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('?'), KeyModifiers::NONE)),
|
||||
Some(Action::OpenHelp)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('q'), KeyModifiers::NONE)),
|
||||
Some(Action::Quit)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Tab, KeyModifiers::NONE)),
|
||||
Some(Action::CycleFocus)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char(' '), KeyModifiers::NONE)),
|
||||
Some(Action::TogglePlay)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_bindings_only_match_their_own_pane() {
|
||||
// 'h' ascends in the library but is unbound in the queue.
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('h'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::LibraryAscend)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('h'), KeyModifiers::NONE)
|
||||
),
|
||||
None
|
||||
);
|
||||
// 'c' clears the queue there but is unbound in the library.
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('c'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::QueueClearKeepCurrent)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('c'), KeyModifiers::NONE)
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_saves_the_queue_or_captures_the_selection_per_pane() {
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::QueueSaveAs)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::LibraryCaptureNode)
|
||||
);
|
||||
// Shift-w is the download capture in each pane: the library
|
||||
// selection, or the whole queue.
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('W'), KeyModifiers::SHIFT)
|
||||
),
|
||||
Some(Action::LibraryDownloadNode)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('W'), KeyModifiers::SHIFT)
|
||||
),
|
||||
Some(Action::QueueDownloadCapture)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_chord_resolves_per_pane() {
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('j'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::LibraryNext)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('j'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::QueueNext)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Enter, KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::LibraryQueueReplace)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Enter, KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::QueuePlaySelected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_is_ignored_for_char_codes() {
|
||||
// Terminals disagree on whether shifted characters report SHIFT.
|
||||
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Library, false, key(KeyCode::Char('?'), mods)),
|
||||
Some(Action::OpenHelp)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Library, false, key(KeyCode::Char('G'), mods)),
|
||||
Some(Action::LibraryLast)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Queue, false, key(KeyCode::Char('K'), mods)),
|
||||
Some(Action::VolumeUp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_creates_only_in_the_library() {
|
||||
// '%' is shifted on most layouts; both modifier reports must match.
|
||||
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Library, false, key(KeyCode::Char('%'), mods)),
|
||||
Some(Action::LibraryCreateNode)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Queue, false, key(KeyCode::Char('%'), mods)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_must_match_exactly() {
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('n'), KeyModifiers::CONTROL)
|
||||
),
|
||||
Some(Action::NextTrack)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('n'), KeyModifiers::NONE)
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('d'), KeyModifiers::CONTROL)
|
||||
),
|
||||
Some(Action::LibraryJumpDown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_and_delete_bind_in_the_library_scope_only() {
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('e'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::LibraryEditNode)
|
||||
);
|
||||
// Plain 'd' renames per scope: library delete vs queue remove-track;
|
||||
// Ctrl+d stays the jump (see control_must_match_exactly).
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
false,
|
||||
key(KeyCode::Char('d'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::LibraryDeleteNode)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('d'), KeyModifiers::NONE)
|
||||
),
|
||||
Some(Action::QueueRemoveTrack)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Queue,
|
||||
false,
|
||||
key(KeyCode::Char('e'), KeyModifiers::NONE)
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_help_swallows_everything_but_close() {
|
||||
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||
// The three close chords work...
|
||||
for code in [KeyCode::Char('?'), KeyCode::Esc, KeyCode::Char('q')] {
|
||||
assert_eq!(
|
||||
lookup(focus, true, key(code, KeyModifiers::NONE)),
|
||||
Some(Action::CloseHelp)
|
||||
);
|
||||
}
|
||||
// ...and every other binding is inert while help is open.
|
||||
assert_eq!(
|
||||
lookup(focus, true, key(KeyCode::Char(' '), KeyModifiers::NONE)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, true, key(KeyCode::Char('j'), KeyModifiers::NONE)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, true, key(KeyCode::Tab, KeyModifiers::NONE)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_scope_never_matches_while_help_is_closed() {
|
||||
// Esc is only bound inside the modal.
|
||||
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chords_are_unique_within_scope() {
|
||||
// Normalize SHIFT away for Char codes, mirroring lookup's comparison.
|
||||
fn norm(b: &Binding) -> (Scope, KeyModifiers, KeyCode) {
|
||||
let mods = if matches!(b.code, KeyCode::Char(_)) {
|
||||
b.mods.difference(KeyModifiers::SHIFT)
|
||||
} else {
|
||||
b.mods
|
||||
};
|
||||
(b.scope, mods, b.code)
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for b in BINDINGS {
|
||||
assert!(
|
||||
seen.insert(norm(b)),
|
||||
"duplicate chord in scope {:?}: {:?}+{:?}",
|
||||
b.scope,
|
||||
b.mods,
|
||||
b.code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_binding_has_a_description() {
|
||||
for b in BINDINGS {
|
||||
assert!(
|
||||
!b.description.trim().is_empty(),
|
||||
"empty description for {:?}",
|
||||
b.action
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_labels_are_human_readable() {
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q");
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('?')), "?");
|
||||
assert_eq!(key_label(KeyModifiers::SHIFT, KeyCode::Char('K')), "K");
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char(' ')), "Space");
|
||||
assert_eq!(
|
||||
key_label(KeyModifiers::CONTROL, KeyCode::Char('d')),
|
||||
"Ctrl+d"
|
||||
);
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Tab), "Tab");
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Enter), "Enter");
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Esc), "Esc");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
//! The help modal: a centered overlay listing usage notes and all key
|
||||
//! bindings, rendered entirely from [`super::bindings::BINDINGS`].
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::bindings::{key_label, Scope, BINDINGS};
|
||||
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
|
||||
|
||||
/// The modal's content: a usage blurb, two binding columns (Global left,
|
||||
/// Library + Queue right, so the whole table fits a typical frame), and a
|
||||
/// close-keys footer derived from the `Scope::Help` bindings.
|
||||
struct HelpContent {
|
||||
usage: Vec<Line<'static>>,
|
||||
left: Vec<Line<'static>>,
|
||||
right: Vec<Line<'static>>,
|
||||
footer: Line<'static>,
|
||||
}
|
||||
|
||||
impl HelpContent {
|
||||
fn build() -> Self {
|
||||
let usage = vec![
|
||||
Line::from("Browse the library (left pane) and manage the play queue (right pane)."),
|
||||
Line::from("Press Tab to switch focus; keys apply globally or to the focused pane."),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
let left = group(Scope::Global, "Global");
|
||||
|
||||
let mut right = group(Scope::Library, "Library");
|
||||
right.push(Line::from(""));
|
||||
right.extend(group(Scope::Queue, "Queue"));
|
||||
|
||||
// All Help-scope chords close the modal; derive their labels instead
|
||||
// of hardcoding key names.
|
||||
let close_keys = BINDINGS
|
||||
.iter()
|
||||
.filter(|b| b.scope == Scope::Help)
|
||||
.map(|b| key_label(b.mods, b.code))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let footer = Line::from(Span::styled(
|
||||
format!("Close help: {close_keys}"),
|
||||
Style::default().fg(COLOR_SECONDARY),
|
||||
));
|
||||
|
||||
Self {
|
||||
usage,
|
||||
left,
|
||||
right,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
|
||||
fn left_width(&self) -> u16 {
|
||||
max_width(&self.left)
|
||||
}
|
||||
|
||||
/// Content size excluding the popup borders.
|
||||
fn size(&self) -> (u16, u16) {
|
||||
let columns = self.left_width() + COLUMN_GAP + max_width(&self.right);
|
||||
let width = columns
|
||||
.max(max_width(&self.usage))
|
||||
.max(self.footer.width() as u16);
|
||||
let height = self.usage.len() as u16
|
||||
+ (self.left.len().max(self.right.len()) as u16)
|
||||
+ 2 // blank line + footer
|
||||
;
|
||||
(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMN_GAP: u16 = 2;
|
||||
|
||||
fn max_width(lines: &[Line<'_>]) -> u16 {
|
||||
lines.iter().map(|l| l.width() as u16).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One scope's bindings as a styled header plus `key description` rows,
|
||||
/// with key labels right-aligned to the group's widest label.
|
||||
fn group(scope: Scope, title: &'static str) -> Vec<Line<'static>> {
|
||||
let entries: Vec<_> = BINDINGS.iter().filter(|b| b.scope == scope).collect();
|
||||
let key_width = entries
|
||||
.iter()
|
||||
.map(|b| key_label(b.mods, b.code).chars().count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut lines = vec![Line::from(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(COLOR_SECONDARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))];
|
||||
lines.extend(entries.iter().map(|b| {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:>key_width$}", key_label(b.mods, b.code)),
|
||||
Style::default().fg(COLOR_PRIMARY),
|
||||
),
|
||||
Span::from(format!(" {}", b.description)),
|
||||
])
|
||||
}));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Render the help modal over the current frame.
|
||||
///
|
||||
/// Draws a `Clear`-backed, centered popup on top of whatever is already in
|
||||
/// the frame. If the frame is smaller than the content, the popup is clamped
|
||||
/// to the frame and overflowing lines are truncated (no scrolling — see
|
||||
/// architecture/help-modal.md, open questions).
|
||||
pub fn render(f: &mut Frame) {
|
||||
let content = HelpContent::build();
|
||||
let area = popup_area(f.area());
|
||||
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(COLOR_PRIMARY))
|
||||
.title("Help");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(content.usage.len() as u16),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(content.left_width() + COLUMN_GAP),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(rows[1]);
|
||||
|
||||
f.render_widget(Paragraph::new(content.usage.clone()), rows[0]);
|
||||
f.render_widget(Paragraph::new(content.left.clone()), columns[0]);
|
||||
f.render_widget(Paragraph::new(content.right.clone()), columns[1]);
|
||||
f.render_widget(Paragraph::new(vec![content.footer.clone()]), rows[2]);
|
||||
}
|
||||
|
||||
/// The centered popup rectangle: sized to the help content but clamped to
|
||||
/// `frame`, never exceeding it.
|
||||
fn popup_area(frame: Rect) -> Rect {
|
||||
let (content_w, content_h) = HelpContent::build().size();
|
||||
let width = content_w.saturating_add(2).min(frame.width);
|
||||
let height = content_h.saturating_add(2).min(frame.height);
|
||||
Rect::new(
|
||||
frame.x + (frame.width - width) / 2,
|
||||
frame.y + (frame.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
fn render_to_buffer(width: u16, height: u16) -> ratatui::buffer::Buffer {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal.draw(render).expect("draw help");
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn buffer_text(buf: &ratatui::buffer::Buffer) -> String {
|
||||
let mut text = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
text.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_bindings_from_the_table() {
|
||||
let text = buffer_text(&render_to_buffer(100, 40));
|
||||
// Spot-check one entry per scope, by description from BINDINGS.
|
||||
assert!(text.contains("Quit"));
|
||||
assert!(text.contains("Enter selected folder"));
|
||||
assert!(text.contains("Remove selected track"));
|
||||
assert!(text.contains("Close help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_explains_basic_usage() {
|
||||
let text = buffer_text(&render_to_buffer(100, 40));
|
||||
// The usage blurb must mention the panes and how to switch focus.
|
||||
assert!(text.contains("Tab"));
|
||||
assert!(text.to_lowercase().contains("library"));
|
||||
assert!(text.to_lowercase().contains("queue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_survives_tiny_terminals() {
|
||||
// Truncation, not panic, on frames smaller than the content.
|
||||
for (w, h) in [(10, 5), (20, 10), (1, 1)] {
|
||||
let _ = render_to_buffer(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_never_exceeds_the_frame() {
|
||||
for (w, h) in [(100, 40), (30, 12), (5, 3)] {
|
||||
let frame = Rect::new(0, 0, w, h);
|
||||
let popup = popup_area(frame);
|
||||
assert!(popup.right() <= frame.right());
|
||||
assert!(popup.bottom() <= frame.bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,14 +12,21 @@ use ratatui::{
|
|||
use crabidy_core::proto::crabidy::LibraryNode;
|
||||
|
||||
use super::{
|
||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
||||
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
|
||||
COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||
};
|
||||
|
||||
pub struct Library {
|
||||
title: String,
|
||||
path: String,
|
||||
/// Whether children may be created under the currently open node
|
||||
/// (mirrors `LibraryNode.is_creatable`). Drives the `%` action and the
|
||||
/// pane-title hint.
|
||||
is_creatable: bool,
|
||||
list: Vec<UiItem>,
|
||||
list_state: ListState,
|
||||
/// The `/` search filter; selection and rendering go through it.
|
||||
filter: Filter,
|
||||
parent: Option<String>,
|
||||
positions: HashMap<String, usize>,
|
||||
tx: Sender<MessageFromUi>,
|
||||
|
|
@ -30,14 +37,76 @@ impl Library {
|
|||
Self {
|
||||
title: "Library".to_string(),
|
||||
path: crabidy_core::ROOT_PATH.to_string(),
|
||||
is_creatable: false,
|
||||
list: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
filter: Filter::default(),
|
||||
positions: HashMap::new(),
|
||||
parent: None,
|
||||
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>> {
|
||||
// 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) {
|
||||
return Some(
|
||||
self.list
|
||||
|
|
@ -47,10 +116,12 @@ impl Library {
|
|||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
return Some(vec![self.list[idx].path.to_string()]);
|
||||
}
|
||||
None
|
||||
// Marks are gated on is_queable when set; the bare selection must be
|
||||
// gated here too, or Enter on a plain folder ships a path the server
|
||||
// can only resolve to nothing (silently ignored, like % / e / d on
|
||||
// items without the capability).
|
||||
let item = self.resolved()?;
|
||||
item.is_queable.then(|| vec![item.path.to_string()])
|
||||
}
|
||||
pub fn ascend(&mut self) {
|
||||
if let Some(parent) = self.parent.as_ref() {
|
||||
|
|
@ -58,8 +129,7 @@ impl Library {
|
|||
}
|
||||
}
|
||||
pub fn dive(&mut self) {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
let item = &self.list[idx];
|
||||
if let Some(item) = self.resolved() {
|
||||
if let UiItemKind::Node = item.kind {
|
||||
let _ = self
|
||||
.tx
|
||||
|
|
@ -103,8 +173,12 @@ impl Library {
|
|||
*self.positions.get(&self.path).unwrap_or(&0)
|
||||
}
|
||||
pub fn toggle_mark(&mut self) {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
let item = &mut self.list[idx];
|
||||
if let Some(real) = self
|
||||
.list_state
|
||||
.selected()
|
||||
.and_then(|view| self.filter.to_real(view))
|
||||
{
|
||||
let item = &mut self.list[real];
|
||||
if !item.is_queable {
|
||||
return;
|
||||
}
|
||||
|
|
@ -120,7 +194,9 @@ impl Library {
|
|||
}
|
||||
}
|
||||
pub fn update(&mut self, node: LibraryNode) {
|
||||
if node.tracks.is_empty() && node.children.is_empty() {
|
||||
// Creatable nodes (e.g. an empty search node) must be enterable even
|
||||
// with nothing in them — the user goes there to create children.
|
||||
if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,55 +204,100 @@ impl Library {
|
|||
self.path = node.path;
|
||||
self.title = node.title;
|
||||
self.parent = node.parent;
|
||||
self.is_creatable = node.is_creatable;
|
||||
|
||||
// Most nodes carry either children or tracks; search term nodes
|
||||
// carry both (track results + artist/album results), so the list is
|
||||
// the concatenation. Tracks first: they are the primary search hits
|
||||
// (architecture/search.md fixes the order tracks, artists, albums).
|
||||
self.list = node
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| UiItem {
|
||||
path: t.path.clone(),
|
||||
title: format!("{} - {}", t.artist, t.title),
|
||||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
is_queable: true,
|
||||
is_creatable: false,
|
||||
is_editable: false,
|
||||
// Tracks carry no wire flags of their own: they inherit
|
||||
// their node's blessing (architecture/captures.md D4).
|
||||
is_deletable: node.tracks_deletable,
|
||||
is_downloadable: node.is_downloadable,
|
||||
is_skipped: t.is_skipped,
|
||||
})
|
||||
.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()));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
let selected = self.list_state.selected();
|
||||
// Render only the rows the filter keeps visible; `view` is the
|
||||
// rendered index the selection bar keys off, `i` the real item.
|
||||
let library_items: Vec<ListItem> = self
|
||||
.list
|
||||
.filter
|
||||
.visible()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let text = if i.marked {
|
||||
.map(|&real| &self.list[real])
|
||||
.enumerate()
|
||||
.map(|(idx, i)| {
|
||||
let mut text = if i.marked {
|
||||
format!("* {}", i.title)
|
||||
} else {
|
||||
i.title.to_string()
|
||||
};
|
||||
let style = if i.marked {
|
||||
if i.is_creatable {
|
||||
text.push_str(" [%]");
|
||||
}
|
||||
// Modifiable items advertise their 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()
|
||||
.fg(COLOR_GREEN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if i.is_skipped {
|
||||
// Skipped tracks have no playable audio; playback
|
||||
// skips them (architecture/incremental-captures.md).
|
||||
Style::default().fg(COLOR_RED)
|
||||
} else if i.is_creatable || i.is_editable || i.is_deletable {
|
||||
Style::default().fg(COLOR_SECONDARY)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
// A colored foreground is unreadable on the light focused
|
||||
// selection bar — switch it to the dark tone there (D7).
|
||||
if focused && selected == Some(idx) && style.fg.is_some() {
|
||||
style = style.fg(COLOR_PRIMARY_DARK);
|
||||
}
|
||||
ListItem::new(Span::from(text)).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -191,7 +312,14 @@ impl Library {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title(self.title.clone()),
|
||||
.title(if let Some(query) = self.filter.query() {
|
||||
// Search mode: show the live query with a cursor.
|
||||
format!("{} — /{query}▏", self.title)
|
||||
} else if self.is_creatable {
|
||||
format!("{} — % to add", self.title)
|
||||
} else {
|
||||
self.title.clone()
|
||||
}),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
|
|
@ -209,15 +337,21 @@ impl Library {
|
|||
|
||||
impl StatefulList for Library {
|
||||
fn get_size(&self) -> usize {
|
||||
self.list.len()
|
||||
// Navigation operates on the filtered (visible) view.
|
||||
self.filter.view_len()
|
||||
}
|
||||
|
||||
fn select(&mut self, idx: Option<usize>) {
|
||||
if let Some(pos) = idx {
|
||||
self.positions
|
||||
.entry(self.path.clone())
|
||||
.and_modify(|e| *e = pos)
|
||||
.or_insert(pos);
|
||||
// Remember the cursor per node as a real index so it survives a
|
||||
// filter (which only reorders the view). With no filter, the
|
||||
// view index is already the real index.
|
||||
if let Some(view) = idx {
|
||||
if let Some(real) = self.filter.to_real(view) {
|
||||
self.positions
|
||||
.entry(self.path.clone())
|
||||
.and_modify(|e| *e = real)
|
||||
.or_insert(real);
|
||||
}
|
||||
}
|
||||
self.list_state.select(idx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,75 @@
|
|||
/// A case-insensitive substring filter over a pane's item list (the
|
||||
/// `/` search, shared by the library and queue panes).
|
||||
///
|
||||
/// The pane keeps its full item list; the filter only records which
|
||||
/// real indices are currently visible, so navigation and — crucially
|
||||
/// for the queue — the real positions sent to the server stay correct.
|
||||
/// An *active* filter with an empty query shows everything (search mode
|
||||
/// is on, nothing typed yet); an inactive filter also shows everything.
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
/// `None` = not searching; `Some(query)` = search mode, query so far.
|
||||
query: Option<String>,
|
||||
/// Real indices currently visible, in list order. Rebuilt by
|
||||
/// [`Self::recompute`]; always `0..len` while inactive or empty.
|
||||
visible: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Whether search mode is on (the query line is showing).
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.query.is_some()
|
||||
}
|
||||
|
||||
/// The current query, if searching.
|
||||
pub fn query(&self) -> Option<&str> {
|
||||
self.query.as_deref()
|
||||
}
|
||||
|
||||
/// Enters/updates/leaves search mode and recomputes the visible set
|
||||
/// against `titles` (the pane's full list, in order).
|
||||
pub fn set<'a>(&mut self, query: Option<String>, titles: impl Iterator<Item = &'a str>) {
|
||||
self.query = query;
|
||||
self.recompute(titles);
|
||||
}
|
||||
|
||||
/// Recomputes the visible indices from the current query against
|
||||
/// `titles`. Call whenever the underlying list changes.
|
||||
pub fn recompute<'a>(&mut self, titles: impl Iterator<Item = &'a str>) {
|
||||
match self.query.as_deref().filter(|q| !q.is_empty()) {
|
||||
None => self.visible = titles.enumerate().map(|(i, _)| i).collect(),
|
||||
Some(query) => {
|
||||
let needle = query.to_lowercase();
|
||||
self.visible = titles
|
||||
.enumerate()
|
||||
.filter(|(_, title)| title.to_lowercase().contains(&needle))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of visible rows.
|
||||
pub fn view_len(&self) -> usize {
|
||||
self.visible.len()
|
||||
}
|
||||
|
||||
/// The real list index behind a view (rendered) index.
|
||||
pub fn to_real(&self, view: usize) -> Option<usize> {
|
||||
self.visible.get(view).copied()
|
||||
}
|
||||
|
||||
/// The view index showing a given real list index, if it is visible.
|
||||
pub fn to_view(&self, real: usize) -> Option<usize> {
|
||||
self.visible.iter().position(|&i| i == real)
|
||||
}
|
||||
|
||||
/// The visible real indices, in order — for rendering.
|
||||
pub fn visible(&self) -> &[usize] {
|
||||
&self.visible
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Move marking stuff here, to be able to use it in queue as well
|
||||
pub trait StatefulList {
|
||||
fn get_size(&self) -> usize;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,7 +12,37 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
|
||||
use super::COLOR_SECONDARY;
|
||||
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
|
||||
|
||||
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
|
||||
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
/// Renders `bins` as full-height vertical bars filling a `width`×`height`
|
||||
/// area: one `Line` per row, top row first. Each column maps to a bin;
|
||||
/// its level in `[0, 1]` fills from the bottom, using partial block
|
||||
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
|
||||
fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static>> {
|
||||
(0..height)
|
||||
.map(|row| {
|
||||
// Row 0 is the top; count cells up from the bottom.
|
||||
let from_bottom = height - 1 - row;
|
||||
let cells: String = (0..width)
|
||||
.map(|col| {
|
||||
let bin = if bins.is_empty() {
|
||||
0
|
||||
} else {
|
||||
(col * bins.len() / width.max(1)).min(bins.len() - 1)
|
||||
};
|
||||
let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let total_eighths = (level * height as f32 * 8.0).round() as usize;
|
||||
let cell = total_eighths.saturating_sub(from_bottom * 8).min(8);
|
||||
BLOCKS[cell]
|
||||
})
|
||||
.collect();
|
||||
Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct NowPlaying {
|
||||
play_state: PlayState,
|
||||
|
|
@ -20,6 +50,13 @@ pub struct NowPlaying {
|
|||
modifiers: QueueModifiers,
|
||||
position: Option<Duration>,
|
||||
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 {
|
||||
|
|
@ -30,6 +67,9 @@ impl Default for NowPlaying {
|
|||
modifiers: QueueModifiers::default(),
|
||||
position: None,
|
||||
track: None,
|
||||
spectrum: Vec::new(),
|
||||
spectrum_enabled: true,
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +97,11 @@ impl NowPlaying {
|
|||
format!("{} by {}", track.title, track.artist,)
|
||||
};
|
||||
// A missing notification daemon must not crash the TUI.
|
||||
// The explicit appname keeps notification-daemon rules
|
||||
// (e.g. mako `app-name=` criteria) stable even if the
|
||||
// binary is renamed or wrapped.
|
||||
if let Err(err) = Notification::new()
|
||||
.appname("crabidy")
|
||||
.summary("Now playing")
|
||||
.body(&body)
|
||||
.show()
|
||||
|
|
@ -70,11 +114,39 @@ impl NowPlaying {
|
|||
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
||||
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) {
|
||||
// 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()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Max(8), Constraint::Max(1)])
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
let media_info_text = if let Some(track) = &self.track {
|
||||
|
|
@ -89,8 +161,10 @@ impl NowPlaying {
|
|||
None => "No album".to_string(),
|
||||
};
|
||||
let mods = format!(
|
||||
"Shuffle: {}, Repeat {}",
|
||||
self.modifiers.shuffle, self.modifiers.repeat
|
||||
"Shuffle: {}, Repeat: {}{}",
|
||||
self.modifiers.shuffle,
|
||||
self.modifiers.repeat,
|
||||
if self.muted { ", Muted" } else { "" },
|
||||
);
|
||||
vec![
|
||||
Line::from(Span::raw(mods)),
|
||||
|
|
@ -142,10 +216,16 @@ impl NowPlaying {
|
|||
.constraints([Constraint::Min(10), Constraint::Max(completion_size)])
|
||||
.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() {
|
||||
0.0
|
||||
} else {
|
||||
position.as_secs_f64().div(duration.as_secs_f64())
|
||||
position
|
||||
.as_secs_f64()
|
||||
.div(duration.as_secs_f64())
|
||||
.clamp(0.0, 1.0)
|
||||
};
|
||||
|
||||
let progress = LineGauge::default()
|
||||
|
|
@ -178,5 +258,131 @@ impl NowPlaying {
|
|||
let time_p = Paragraph::new(Line::from(time_text));
|
||||
f.render_widget(time_p, elapsed_layout[1]);
|
||||
}
|
||||
|
||||
// The spectrum: full-height accent bars filling the region left
|
||||
// below the progress. Columns stretch across the pane width
|
||||
// regardless of the server's bin count.
|
||||
if self.spectrum_enabled && !self.spectrum.is_empty() {
|
||||
let area = now_playing_layout[2];
|
||||
let (width, height) = (area.width as usize, area.height as usize);
|
||||
if width > 0 && height > 0 {
|
||||
let lines = spectrum_lines(&self.spectrum, width, height);
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
/// A now-playing pane mid-track, built directly (no `update_track`,
|
||||
/// which fires a desktop notification).
|
||||
fn now_playing(position_ms: u32, duration_ms: u32) -> NowPlaying {
|
||||
NowPlaying {
|
||||
play_state: PlayState::Playing,
|
||||
duration: Some(Duration::from_millis(duration_ms.into())),
|
||||
modifiers: QueueModifiers::default(),
|
||||
position: Some(Duration::from_millis(position_ms.into())),
|
||||
track: Some(Track {
|
||||
path: "/fs/radio.cbd-track.toml".to_string(),
|
||||
artist: "artist".to_string(),
|
||||
title: "title".to_string(),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
}),
|
||||
spectrum: Vec::new(),
|
||||
spectrum_enabled: true,
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(pane: &NowPlaying) {
|
||||
let backend = TestBackend::new(60, 12);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|f| pane.render(f, f.area()))
|
||||
.expect("draw must not panic");
|
||||
}
|
||||
|
||||
/// Renders and returns the buffer rows as strings.
|
||||
fn rendered_rows(pane: &NowPlaying) -> Vec<String> {
|
||||
let backend = TestBackend::new(60, 12);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol().to_string())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectrum_lines_fill_full_height_columns() {
|
||||
// A full-level bin fills every row of its column with the full
|
||||
// block; a zero bin leaves every row blank.
|
||||
let lines = spectrum_lines(&[1.0, 0.0], 2, 4);
|
||||
assert_eq!(lines.len(), 4, "one line per row");
|
||||
let text: Vec<String> = lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
|
||||
.collect();
|
||||
// Column 0 (level 1.0) is full in every row; column 1 (0.0) empty.
|
||||
assert!(text.iter().all(|row| row.starts_with('█')), "{text:?}");
|
||||
assert!(text.iter().all(|row| row.ends_with(' ')), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectrum_lines_grow_from_the_bottom() {
|
||||
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill.
|
||||
let lines = spectrum_lines(&[0.5], 1, 4);
|
||||
let col: Vec<char> = lines
|
||||
.iter()
|
||||
.map(|l| l.spans[0].content.chars().next().unwrap())
|
||||
.collect();
|
||||
// Top rows empty, bottom rows full — bars rise from the floor.
|
||||
assert_eq!(col[0], ' ', "top empty: {col:?}");
|
||||
assert_eq!(col[3], '█', "bottom full: {col:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spectrum_renders_block_glyphs_when_enabled() {
|
||||
let mut pane = now_playing(10_000, 60_000);
|
||||
pane.update_spectrum(vec![1.0; 24]);
|
||||
let rows = rendered_rows(&pane);
|
||||
// Full-level bars fill several rows with the full block.
|
||||
let full_rows = rows.iter().filter(|r| r.contains('█')).count();
|
||||
assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spectrum_row_is_hidden_when_disabled() {
|
||||
let mut pane = now_playing(10_000, 60_000);
|
||||
pane.set_spectrum_enabled(false);
|
||||
pane.update_spectrum(vec![1.0; 24]);
|
||||
let rows = rendered_rows(&pane);
|
||||
assert!(
|
||||
!rows.iter().any(|r| r.contains('█')),
|
||||
"disabled spectrum must not draw bars: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The position can overrun a stale or wrong duration (streams,
|
||||
/// hand-written track files); the gauge must clamp instead of hitting
|
||||
/// ratatui's `ratio should be between 0 and 1` panic.
|
||||
#[test]
|
||||
fn progress_gauge_survives_position_past_duration() {
|
||||
render(&now_playing(90_000, 60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_gauge_survives_a_zero_duration() {
|
||||
render(&now_playing(5_000, 0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,24 @@ use ratatui::{
|
|||
use crabidy_core::proto::crabidy::Queue as QueueData;
|
||||
|
||||
use super::{
|
||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED,
|
||||
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
||||
COLOR_RED, COLOR_SECONDARY,
|
||||
};
|
||||
|
||||
pub struct Queue {
|
||||
current_position: usize,
|
||||
list: Vec<UiItem>,
|
||||
list_state: ListState,
|
||||
/// The `/` search filter; selection and rendering go through it, and
|
||||
/// it maps view rows back to real queue positions before they are
|
||||
/// sent to the server.
|
||||
filter: Filter,
|
||||
/// True while the server is still resolving queued paths (from
|
||||
/// `Queue.resolving`); the pane then renders an animated-dots
|
||||
/// pseudo-item after the last track. The pseudo-item exists only at
|
||||
/// render time — it never enters `list`, so selection and removal
|
||||
/// cannot reach it.
|
||||
resolving: bool,
|
||||
tx: Sender<MessageFromUi>,
|
||||
}
|
||||
|
||||
|
|
@ -26,9 +37,35 @@ impl Queue {
|
|||
current_position: 0,
|
||||
list: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
filter: Filter::default(),
|
||||
resolving: false,
|
||||
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) {
|
||||
let _ = self.tx.send(MessageFromUi::NextTrack);
|
||||
}
|
||||
|
|
@ -36,15 +73,19 @@ impl Queue {
|
|||
let _ = self.tx.send(MessageFromUi::PrevTrack);
|
||||
}
|
||||
pub fn play_selected(&self) {
|
||||
if let Some(pos) = self.selected() {
|
||||
if let Some(pos) = self.selected_position() {
|
||||
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
||||
}
|
||||
}
|
||||
pub fn select_current(&mut self) {
|
||||
self.select(Some(self.current_position));
|
||||
// Map the real playing position to its view row; if the filter
|
||||
// hides it, leave the cursor where it is.
|
||||
if let Some(view) = self.filter.to_view(self.current_position) {
|
||||
self.select(Some(view));
|
||||
}
|
||||
}
|
||||
pub fn remove_track(&mut self) {
|
||||
if let Some(pos) = self.selected() {
|
||||
if let Some(pos) = self.selected_position() {
|
||||
// FIXME: mark multiple tracks on queue and remove them
|
||||
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
|
||||
}
|
||||
|
|
@ -54,6 +95,7 @@ impl Queue {
|
|||
}
|
||||
pub fn update_queue(&mut self, queue: QueueData) {
|
||||
self.current_position = queue.current_position as usize;
|
||||
self.resolving = queue.resolving;
|
||||
self.list = queue
|
||||
.tracks
|
||||
.iter()
|
||||
|
|
@ -63,33 +105,74 @@ impl Queue {
|
|||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
is_queable: false,
|
||||
is_creatable: false,
|
||||
is_editable: false,
|
||||
is_deletable: false,
|
||||
is_downloadable: false,
|
||||
is_skipped: t.is_skipped,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The queue is re-sent often (position ticks, resolving); keep
|
||||
// any active search and just recompute which rows it matches.
|
||||
self.filter
|
||||
.recompute(self.list.iter().map(|i| i.title.as_str()));
|
||||
self.update_selection();
|
||||
}
|
||||
|
||||
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
let queue_items: Vec<ListItem> = self
|
||||
.list
|
||||
let selected = self.list_state.selected();
|
||||
// Render only the visible rows; `view` is the rendered index the
|
||||
// selection bar keys off, `real` the queue position (which drives
|
||||
// the playing marker).
|
||||
let mut queue_items: Vec<ListItem> = self
|
||||
.filter
|
||||
.visible()
|
||||
.iter()
|
||||
.map(|&real| (real, &self.list[real]))
|
||||
.enumerate()
|
||||
.map(|(idx, item)| {
|
||||
let active = idx == self.current_position;
|
||||
.map(|(idx, (real, item))| {
|
||||
let active = real == self.current_position;
|
||||
|
||||
let title = if active {
|
||||
format!("> {}", item.title)
|
||||
} else {
|
||||
item.title.to_string()
|
||||
};
|
||||
let style = if active {
|
||||
let mut style = if active {
|
||||
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 {
|
||||
Style::default()
|
||||
};
|
||||
// A colored foreground is unreadable on the light focused
|
||||
// selection bar — switch it to the dark tone there (D7).
|
||||
if focused && selected == Some(idx) && style.fg.is_some() {
|
||||
style = style.fg(COLOR_PRIMARY_DARK);
|
||||
}
|
||||
ListItem::new(Span::from(title)).style(style)
|
||||
})
|
||||
.collect();
|
||||
if self.resolving {
|
||||
// Render-time pseudo-item: more tracks are on their way. It is
|
||||
// not part of `self.list`, so it can never be selected or
|
||||
// removed. The render loop redraws at least every 100 ms,
|
||||
// which keeps the dots moving.
|
||||
static RENDERED_FIRST_AT: std::sync::OnceLock<std::time::Instant> =
|
||||
std::sync::OnceLock::new();
|
||||
let elapsed = RENDERED_FIRST_AT
|
||||
.get_or_init(std::time::Instant::now)
|
||||
.elapsed()
|
||||
.as_millis();
|
||||
queue_items.push(
|
||||
ListItem::new(Span::from(Self::loading_dots(elapsed)))
|
||||
.style(Style::default().fg(COLOR_SECONDARY)),
|
||||
);
|
||||
}
|
||||
|
||||
let queue_list = List::new(queue_items)
|
||||
.block(
|
||||
|
|
@ -101,7 +184,10 @@ impl Queue {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title("Queue"),
|
||||
.title(match self.filter.query() {
|
||||
Some(query) => format!("Queue — /{query}▏"),
|
||||
None => "Queue".to_string(),
|
||||
}),
|
||||
)
|
||||
.highlight_style(Style::default().bg(if focused {
|
||||
COLOR_PRIMARY
|
||||
|
|
@ -115,7 +201,8 @@ impl Queue {
|
|||
|
||||
impl StatefulList for Queue {
|
||||
fn get_size(&self) -> usize {
|
||||
self.list.len()
|
||||
// Navigation operates on the filtered (visible) view.
|
||||
self.filter.view_len()
|
||||
}
|
||||
|
||||
fn select(&mut self, idx: Option<usize>) {
|
||||
|
|
@ -126,3 +213,207 @@ impl StatefulList for Queue {
|
|||
self.list_state.selected()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crabidy_core::proto::crabidy::Track;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
fn queue_data(titles: &[&str], resolving: bool) -> QueueData {
|
||||
QueueData {
|
||||
timestamp: 0,
|
||||
current_position: 0,
|
||||
tracks: titles
|
||||
.iter()
|
||||
.map(|t| Track {
|
||||
path: format!("/tidal/x/{t}"),
|
||||
artist: "artist".to_string(),
|
||||
title: t.to_string(),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
})
|
||||
.collect(),
|
||||
resolving,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_rows(queue: &mut Queue) -> Vec<String> {
|
||||
let backend = TestBackend::new(40, 8);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|f| queue.render(f, f.area(), true))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol().to_string())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The row content inside the borders, trimmed.
|
||||
fn inner_rows(queue: &mut Queue) -> Vec<String> {
|
||||
rendered_rows(queue)
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|row| row.trim_matches(['│', ' ']).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dots_row_count(rows: &[String]) -> usize {
|
||||
rows.iter()
|
||||
.filter(|row| !row.is_empty() && row.chars().all(|c| c == '.'))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_queue_renders_trailing_dots_item() {
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
queue.update_queue(queue_data(&["one", "two"], true));
|
||||
let rows = inner_rows(&mut queue);
|
||||
assert_eq!(dots_row_count(&rows), 1, "rows: {rows:?}");
|
||||
// The dots trail the tracks: they come after the last track row.
|
||||
let last_track = rows.iter().position(|r| r.contains("two")).unwrap();
|
||||
let dots = rows
|
||||
.iter()
|
||||
.position(|r| !r.is_empty() && r.chars().all(|c| c == '.'))
|
||||
.unwrap();
|
||||
assert!(last_track < dots, "rows: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_queue_has_no_dots_item() {
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
queue.update_queue(queue_data(&["one", "two"], false));
|
||||
let rows = inner_rows(&mut queue);
|
||||
assert_eq!(dots_row_count(&rows), 0, "rows: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_flag_clears_with_the_next_update() {
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
queue.update_queue(queue_data(&["one"], true));
|
||||
queue.update_queue(queue_data(&["one", "two"], false));
|
||||
let rows = inner_rows(&mut queue);
|
||||
assert_eq!(dots_row_count(&rows), 0, "rows: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_item_is_outside_the_selectable_list() {
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
queue.update_queue(queue_data(&["one", "two"], true));
|
||||
// Selection, removal and navigation all key off get_size; the
|
||||
// pseudo-item must not be reachable through any of them.
|
||||
assert_eq!(queue.get_size(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_dots_cycle_one_to_three() {
|
||||
assert_eq!(Queue::loading_dots(0), ".");
|
||||
assert_eq!(Queue::loading_dots(400), "..");
|
||||
assert_eq!(Queue::loading_dots(800), "...");
|
||||
assert_eq!(Queue::loading_dots(1200), ".");
|
||||
}
|
||||
|
||||
/// Renders and returns the buffer plus the y of the row containing
|
||||
/// `needle` and the x of its first character.
|
||||
fn render_and_find(queue: &mut Queue, needle: &str) -> (ratatui::buffer::Buffer, u16, u16) {
|
||||
let backend = TestBackend::new(40, 8);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|f| queue.render(f, f.area(), true))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
for y in 0..buffer.area.height {
|
||||
let row: String = (0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol().to_string())
|
||||
.collect();
|
||||
if let Some(col) = row.find(needle) {
|
||||
return (buffer, col as u16, y);
|
||||
}
|
||||
}
|
||||
panic!("row containing {needle:?} not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_tracks_render_red() {
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
let mut data = queue_data(&["one", "two"], false);
|
||||
data.tracks[1].is_skipped = true;
|
||||
queue.update_queue(data);
|
||||
// Selection sits on row 0; the unselected skipped row is red.
|
||||
let (buffer, x, y) = render_and_find(&mut queue, "artist - two");
|
||||
assert_eq!(
|
||||
buffer[(x, y)].style().fg,
|
||||
Some(super::COLOR_RED),
|
||||
"skipped tracks must be red"
|
||||
);
|
||||
// The playing track keeps its red marker when not under the bar.
|
||||
queue.select(Some(1));
|
||||
let (buffer, x, y) = render_and_find(&mut queue, "> artist - one");
|
||||
assert_eq!(buffer[(x, y)].style().fg, Some(super::COLOR_RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_narrows_the_view_and_maps_removal_to_the_real_position() {
|
||||
let (tx, rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
|
||||
// "gam" matches only the third track (real position 2).
|
||||
queue.set_filter(Some("gam".to_string()));
|
||||
assert_eq!(queue.get_size(), 1, "one visible row");
|
||||
// The single visible row is view index 0; removing it must send
|
||||
// the *real* queue position, not the view index.
|
||||
queue.select(Some(0));
|
||||
queue.remove_track();
|
||||
match rx.try_recv() {
|
||||
Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![2]),
|
||||
other => panic!("expected RemoveTracks([2]), got {:?}", other.is_ok()),
|
||||
}
|
||||
// Clearing the filter restores the full view.
|
||||
queue.set_filter(None);
|
||||
assert_eq!(queue.get_size(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filter_survives_queue_updates() {
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
|
||||
queue.set_filter(Some("beta".to_string()));
|
||||
assert_eq!(queue.get_size(), 1);
|
||||
// A stream re-send (e.g. a position tick) keeps the active search
|
||||
// and just recomputes which rows match.
|
||||
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
|
||||
assert_eq!(queue.get_size(), 1, "search preserved across updates");
|
||||
assert_eq!(queue.filter_query(), Some("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_rows_darken_under_the_focused_selection_bar() {
|
||||
// A red (skipped) row under the light focused selection bar was
|
||||
// unreadable; the foreground switches to the dark tone there
|
||||
// (architecture/incremental-captures.md D7).
|
||||
let (tx, _rx) = flume::unbounded();
|
||||
let mut queue = Queue::new(tx);
|
||||
let mut data = queue_data(&["one", "two"], false);
|
||||
data.tracks[1].is_skipped = true;
|
||||
queue.update_queue(data);
|
||||
queue.select(Some(1));
|
||||
let (buffer, x, y) = render_and_find(&mut queue, "two");
|
||||
assert_eq!(
|
||||
buffer[(x, y)].style().fg,
|
||||
Some(super::COLOR_PRIMARY_DARK),
|
||||
"selected colored rows must use the dark foreground"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,23 @@ pub struct ServerConfig {
|
|||
#[default("http://127.0.0.1:50051".to_string())]
|
||||
#[clap(short, long)]
|
||||
pub address: String,
|
||||
|
||||
/// Role to authenticate as: "owner", "queue-owner" or
|
||||
/// "queue-appender" (architecture/roles-auth.md). Leave empty
|
||||
/// against a server without configured auth.
|
||||
#[default(String::new())]
|
||||
#[clap(short, long)]
|
||||
pub user: String,
|
||||
|
||||
/// Password for the role. Stored in plaintext — keep the config
|
||||
/// file private. Never logged.
|
||||
#[default(String::new())]
|
||||
#[clap(short, long)]
|
||||
pub password: String,
|
||||
|
||||
/// Show the frequency-spectrum bars under the track progress
|
||||
/// (architecture/spectrum.md). On by default; set false to hide.
|
||||
#[default(true)]
|
||||
#[clap(long)]
|
||||
pub spectrum: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
//! The cbd-tui client as a library: the server-facing orchestration loop
|
||||
//! and the terminal UI loop, exposed as [`run`] so both the standalone
|
||||
//! `cbd-tui` binary and the bundled `cbd` binary can host them
|
||||
//! (architecture/cbd-bundle.md D1). Tracing setup stays with the
|
||||
//! binaries — where logs go is a hosting decision.
|
||||
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod rpc;
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
io,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use flume::{Receiver, Sender};
|
||||
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use tokio::select;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use app::{bindings, App, DispatchResult, MessageFromUi, MessageToUi};
|
||||
use config::Config;
|
||||
use rpc::RpcClient;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Runs the client: the rpc orchestration loop on the runtime, the
|
||||
/// blocking terminal UI on its own thread. Returns when the user quits
|
||||
/// the UI.
|
||||
pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
|
||||
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
|
||||
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
|
||||
|
||||
// FIXME: unwrap
|
||||
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
||||
|
||||
let spectrum_enabled = config.server.spectrum;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
run_ui(ui_tx, ui_rx, spectrum_enabled);
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn orchestrate(
|
||||
config: &'static Config,
|
||||
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
info!(address = config.server.address, "connecting to server");
|
||||
let mut rpc_client = rpc::RpcClient::connect(&config.server).await?;
|
||||
|
||||
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
|
||||
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
||||
}
|
||||
|
||||
let init_data = rpc_client.init().await?;
|
||||
info!("received initial state from server");
|
||||
tx.send_async(MessageToUi::Init(init_data)).await?;
|
||||
|
||||
loop {
|
||||
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
|
||||
error!("request to server failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll(
|
||||
rpc_client: &mut RpcClient,
|
||||
rx: &Receiver<MessageFromUi>,
|
||||
tx: &Sender<MessageToUi>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
select! {
|
||||
Ok(msg) = &mut rx.recv_async() => {
|
||||
match msg {
|
||||
MessageFromUi::GetLibraryNode(path) => {
|
||||
if let Some(node) = rpc_client.get_library_node(&path).await? {
|
||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||
}
|
||||
},
|
||||
MessageFromUi::CreateNode { parent_path, title } => {
|
||||
// Navigates the library into the created node on
|
||||
// success; on failure the library stays where it is.
|
||||
match rpc_client.create_library_node(&parent_path, &title).await {
|
||||
Ok(node) => {
|
||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||
}
|
||||
Err(err) => {
|
||||
error!(parent_path, title, "failed to create node: {err}");
|
||||
}
|
||||
}
|
||||
},
|
||||
MessageFromUi::RenameNode { path, new_title } => {
|
||||
// Navigates the library into the renamed node on success;
|
||||
// on failure the library stays where it is.
|
||||
match rpc_client.rename_library_node(&path, &new_title).await {
|
||||
Ok(node) => {
|
||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||
}
|
||||
Err(err) => {
|
||||
error!(path, new_title, "failed to rename node: {err}");
|
||||
}
|
||||
}
|
||||
},
|
||||
MessageFromUi::DeleteNode { path } => {
|
||||
// Shows the refreshed parent listing on success; on
|
||||
// failure the library stays where it is.
|
||||
match rpc_client.delete_library_node(&path).await {
|
||||
Ok(parent) => {
|
||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(parent.clone()));
|
||||
}
|
||||
Err(err) => {
|
||||
error!(path, "failed to delete node: {err}");
|
||||
}
|
||||
}
|
||||
},
|
||||
MessageFromUi::AppendTracks(uuids) => {
|
||||
rpc_client.append_tracks(uuids).await?
|
||||
}
|
||||
MessageFromUi::QueueTracks(uuids) => {
|
||||
rpc_client.queue_tracks(uuids).await?
|
||||
}
|
||||
MessageFromUi::InsertTracks(uuids, pos) => {
|
||||
rpc_client.insert_tracks(uuids, pos).await?
|
||||
}
|
||||
MessageFromUi::RemoveTracks(positions) => {
|
||||
rpc_client.remove_tracks(positions).await?
|
||||
}
|
||||
MessageFromUi::ReplaceQueue(uuids) => {
|
||||
rpc_client.replace_queue(uuids).await?
|
||||
}
|
||||
MessageFromUi::NextTrack => {
|
||||
rpc_client.next_track().await?
|
||||
}
|
||||
MessageFromUi::PrevTrack => {
|
||||
rpc_client.prev_track().await?
|
||||
}
|
||||
MessageFromUi::RestartTrack => {
|
||||
rpc_client.restart_track().await?
|
||||
}
|
||||
MessageFromUi::SetCurrentTrack(pos) => {
|
||||
rpc_client.set_current_track(pos).await?
|
||||
}
|
||||
MessageFromUi::TogglePlay => {
|
||||
rpc_client.toggle_play().await?
|
||||
}
|
||||
MessageFromUi::ChangeVolume(delta) => {
|
||||
rpc_client.change_volume(delta).await?
|
||||
}
|
||||
MessageFromUi::ToggleMute => {
|
||||
rpc_client.toggle_mute().await?
|
||||
}
|
||||
MessageFromUi::ToggleShuffle => {
|
||||
rpc_client.toggle_shuffle().await?
|
||||
}
|
||||
MessageFromUi::ToggleRepeat => {
|
||||
rpc_client.toggle_repeat().await?
|
||||
}
|
||||
MessageFromUi::ClearQueue(exclude_current) => {
|
||||
rpc_client.clear_queue(exclude_current).await?
|
||||
}
|
||||
MessageFromUi::SaveQueue(name) => {
|
||||
// A rejected save (bad name, empty queue) must not tear
|
||||
// down the poll loop; the server logs the cause.
|
||||
if let Err(err) = rpc_client.save_queue(name.clone()).await {
|
||||
error!(name, "failed to save queue: {err}");
|
||||
}
|
||||
}
|
||||
MessageFromUi::CaptureNode { path, name, download } => {
|
||||
// A rejected capture (bad name, over-cap subtree, failed
|
||||
// download) must not tear down the poll loop either.
|
||||
if let Err(err) = rpc_client
|
||||
.capture_library_node(path.clone(), name.clone(), download)
|
||||
.await
|
||||
{
|
||||
error!(path, name, download, "failed to capture subtree: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(resp) = rpc_client.update_stream.next() => {
|
||||
match resp {
|
||||
Ok(resp) => {
|
||||
if let Some(update) = resp.update {
|
||||
tx.send_async(MessageToUi::Update(update)).await?;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("update stream broke, reconnecting: {err}");
|
||||
rpc_client.reconnect_update_stream().await;
|
||||
info!("update stream reconnected");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled: bool) {
|
||||
// setup terminal
|
||||
enable_raw_mode().unwrap();
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap();
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
// create app and run it
|
||||
let mut app = App::new(tx);
|
||||
app.now_playing.set_spectrum_enabled(spectrum_enabled);
|
||||
let tick_rate = Duration::from_millis(100);
|
||||
let mut last_tick = Instant::now();
|
||||
|
||||
loop {
|
||||
for message in rx.try_iter() {
|
||||
match message {
|
||||
MessageToUi::ReplaceLibraryNode(node) => {
|
||||
app.library.update(node);
|
||||
}
|
||||
MessageToUi::Init(init_data) => {
|
||||
if let Some(queue) = init_data.queue {
|
||||
app.queue.update_queue(queue);
|
||||
}
|
||||
if let Some(track) = init_data.queue_track {
|
||||
app.now_playing.update_track(track.track);
|
||||
app.queue.update_position(track.queue_position as usize);
|
||||
}
|
||||
if let Ok(ps) = PlayState::try_from(init_data.play_state) {
|
||||
app.now_playing.update_play_state(ps);
|
||||
}
|
||||
if let Some(mods) = init_data.mods {
|
||||
app.now_playing.update_modifiers(&mods);
|
||||
}
|
||||
}
|
||||
MessageToUi::Update(update) => match update {
|
||||
StreamUpdate::Queue(queue) => {
|
||||
app.queue.update_queue(queue);
|
||||
}
|
||||
StreamUpdate::QueueTrack(track) => {
|
||||
app.now_playing.update_track(track.track);
|
||||
app.queue.update_position(track.queue_position as usize);
|
||||
}
|
||||
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
|
||||
StreamUpdate::PlayState(play_state) => {
|
||||
if let Ok(ps) = PlayState::try_from(play_state) {
|
||||
app.now_playing.update_play_state(ps);
|
||||
}
|
||||
}
|
||||
StreamUpdate::Mods(mods) => {
|
||||
app.now_playing.update_modifiers(&mods);
|
||||
}
|
||||
StreamUpdate::Mute(muted) => app.now_playing.update_mute(muted),
|
||||
StreamUpdate::Volume(_) => { /* FIXME: implement */ }
|
||||
StreamUpdate::CaptureProgress(progress) => {
|
||||
app.captures.apply(progress);
|
||||
}
|
||||
StreamUpdate::Spectrum(frame) => {
|
||||
app.now_playing.update_spectrum(frame.bins);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = terminal.draw(|f| app.render(f)) {
|
||||
error!("failed to draw frame: {err}");
|
||||
break;
|
||||
}
|
||||
|
||||
let timeout = tick_rate
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout).unwrap() {
|
||||
if let Event::Key(key) = event::read().unwrap() {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
// The overlays are strictly modal: while one is open,
|
||||
// keys answer it and the bindings table (including
|
||||
// quit) is unreachable.
|
||||
if app.confirm.is_some() {
|
||||
app.handle_confirm_key(key);
|
||||
} else if app.search.is_some() {
|
||||
app.handle_search_key(key);
|
||||
} else if app.input.is_some() {
|
||||
app.handle_input_key(key);
|
||||
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
||||
if app.dispatch(action) == DispatchResult::Quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// restore terminal
|
||||
disable_raw_mode().unwrap();
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)
|
||||
.unwrap();
|
||||
terminal.show_cursor().unwrap();
|
||||
}
|
||||
|
|
@ -1,33 +1,11 @@
|
|||
mod app;
|
||||
mod config;
|
||||
mod rpc;
|
||||
//! The standalone TUI binary: file-based tracing (the terminal belongs
|
||||
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives
|
||||
//! in the library so the bundled `cbd` binary can host it too
|
||||
//! (architecture/cbd-bundle.md D1).
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
io,
|
||||
sync::OnceLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
|
||||
|
||||
use 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};
|
||||
use cbd_tui::config::Config;
|
||||
|
||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
|
||||
|
|
@ -67,316 +45,5 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
|||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _log_guard = init_tracing();
|
||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
||||
|
||||
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
|
||||
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
|
||||
|
||||
// FIXME: unwrap
|
||||
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
||||
|
||||
tokio::task::spawn_blocking(|| {
|
||||
run_ui(ui_tx, ui_rx);
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn orchestrate(
|
||||
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();
|
||||
cbd_tui::run(config).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
|
||||
ClearQueueRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse,
|
||||
InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
||||
RemoveRequest, ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest,
|
||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
||||
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||
ToggleShuffleRequest,
|
||||
};
|
||||
|
||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||
|
||||
use base64::Engine;
|
||||
use tonic::{
|
||||
metadata::MetadataValue,
|
||||
service::{interceptor::InterceptedService, Interceptor},
|
||||
transport::{Channel, Endpoint},
|
||||
Request, Streaming,
|
||||
Request, Status, Streaming,
|
||||
};
|
||||
|
||||
// FIXME: use anyhow + thiserror
|
||||
|
|
@ -29,16 +34,78 @@ impl fmt::Display for RpcClientError {
|
|||
|
||||
impl Error for RpcClientError {}
|
||||
|
||||
/// Attaches the configured `authorization: Basic …` header to every
|
||||
/// outgoing request (architecture/roles-auth.md). Without configured
|
||||
/// credentials it attaches nothing, keeping the zero-config local
|
||||
/// setup working against an open server. The header value is a secret
|
||||
/// and never logged.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthInterceptor {
|
||||
header: Option<MetadataValue<tonic::metadata::Ascii>>,
|
||||
}
|
||||
|
||||
impl AuthInterceptor {
|
||||
/// `user` empty means "no credentials".
|
||||
fn new(user: &str, password: &str) -> Result<Self, Box<dyn Error>> {
|
||||
if user.is_empty() {
|
||||
return Ok(Self { header: None });
|
||||
}
|
||||
let encoded =
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
|
||||
let header = format!("Basic {encoded}")
|
||||
.parse()
|
||||
// The value is base64: this cannot fail on credential
|
||||
// contents, only on programmer error.
|
||||
.map_err(|_| "cannot encode credentials header")?;
|
||||
Ok(Self {
|
||||
header: Some(header),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Interceptor for AuthInterceptor {
|
||||
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
|
||||
if let Some(header) = &self.header {
|
||||
request
|
||||
.metadata_mut()
|
||||
.insert("authorization", header.clone());
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// The service client with the auth interceptor baked in.
|
||||
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
|
||||
|
||||
pub struct RpcClient {
|
||||
library_node_cache: HashMap<String, LibraryNode>,
|
||||
client: CrabidyServiceClient<Channel>,
|
||||
client: Client,
|
||||
pub update_stream: Streaming<GetUpdateStreamResponse>,
|
||||
}
|
||||
|
||||
/// Whether a library listing may be served from the session cache.
|
||||
///
|
||||
/// The server-side folder providers mutate behind the client's back —
|
||||
/// 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 {
|
||||
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
|
||||
let endpoint = Endpoint::from_static(addr).connect_lazy();
|
||||
let mut client = CrabidyServiceClient::new(endpoint);
|
||||
pub async fn connect(
|
||||
server: &'static crate::config::ServerConfig,
|
||||
) -> Result<RpcClient, Box<dyn Error>> {
|
||||
let endpoint = Endpoint::from_static(&server.address).connect_lazy();
|
||||
let interceptor = AuthInterceptor::new(&server.user, &server.password)?;
|
||||
let mut client = CrabidyServiceClient::with_interceptor(endpoint, interceptor);
|
||||
|
||||
let update_stream = Self::get_update_stream(&mut client).await;
|
||||
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
|
||||
|
|
@ -50,9 +117,7 @@ impl RpcClient {
|
|||
})
|
||||
}
|
||||
|
||||
async fn get_update_stream(
|
||||
client: &mut CrabidyServiceClient<Channel>,
|
||||
) -> Streaming<GetUpdateStreamResponse> {
|
||||
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
|
||||
loop {
|
||||
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
|
||||
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
|
||||
|
|
@ -77,7 +142,7 @@ impl RpcClient {
|
|||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
|
||||
if self.library_node_cache.contains_key(path) {
|
||||
if is_cacheable(path) && self.library_node_cache.contains_key(path) {
|
||||
return Ok(self.library_node_cache.get(path));
|
||||
}
|
||||
let get_library_node_request = Request::new(GetLibraryNodeRequest {
|
||||
|
|
@ -88,6 +153,8 @@ impl RpcClient {
|
|||
.get_library_node(get_library_node_request)
|
||||
.await?;
|
||||
if let Some(library_node) = response.into_inner().node {
|
||||
// Non-cacheable nodes are stored too (the return borrows from
|
||||
// the map) — they are just always refetched above.
|
||||
self.library_node_cache
|
||||
.insert(path.to_string(), library_node);
|
||||
return Ok(self.library_node_cache.get(path));
|
||||
|
|
@ -95,6 +162,85 @@ impl RpcClient {
|
|||
Err(Box::new(RpcClientError::NotFound))
|
||||
}
|
||||
|
||||
/// Creates a child node under a creatable parent and returns it.
|
||||
///
|
||||
/// Cache contract: the created node is inserted into
|
||||
/// `library_node_cache`, and the *parent's* cache entry is evicted —
|
||||
/// its child listing just changed and would otherwise be served stale
|
||||
/// when the user ascends back to it.
|
||||
pub async fn create_library_node(
|
||||
&mut self,
|
||||
parent_path: &str,
|
||||
title: &str,
|
||||
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||
let request = Request::new(CreateLibraryNodeRequest {
|
||||
parent_path: parent_path.to_string(),
|
||||
title: title.to_string(),
|
||||
});
|
||||
let response = self.client.create_library_node(request).await?;
|
||||
let Some(node) = response.into_inner().node else {
|
||||
return Err(Box::new(RpcClientError::NotFound));
|
||||
};
|
||||
// The parent's child listing just changed; a cached copy would hide
|
||||
// the new node when the user ascends back to it.
|
||||
self.library_node_cache.remove(parent_path);
|
||||
let path = node.path.clone();
|
||||
self.library_node_cache.insert(path.clone(), node);
|
||||
Ok(&self.library_node_cache[&path])
|
||||
}
|
||||
|
||||
/// Renames an editable node and returns it at its (changed) path.
|
||||
///
|
||||
/// Cache contract: the old path's entry and the parent's entry are
|
||||
/// evicted (the parent's child listing changed; the old path is dead),
|
||||
/// and the renamed node is inserted under its new path.
|
||||
pub async fn rename_library_node(
|
||||
&mut self,
|
||||
path: &str,
|
||||
new_title: &str,
|
||||
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||
let request = Request::new(RenameLibraryNodeRequest {
|
||||
path: path.to_string(),
|
||||
new_title: new_title.to_string(),
|
||||
});
|
||||
let response = self.client.rename_library_node(request).await?;
|
||||
let Some(node) = response.into_inner().node else {
|
||||
return Err(Box::new(RpcClientError::NotFound));
|
||||
};
|
||||
// The old path is dead and the parent's child listing changed;
|
||||
// cached copies would resurrect the old term.
|
||||
self.library_node_cache.remove(path);
|
||||
if let Some(parent) = crabidy_core::parent_path(path) {
|
||||
self.library_node_cache.remove(parent);
|
||||
}
|
||||
let new_path = node.path.clone();
|
||||
self.library_node_cache.insert(new_path.clone(), node);
|
||||
Ok(&self.library_node_cache[&new_path])
|
||||
}
|
||||
|
||||
/// Deletes a node and returns the refreshed parent listing.
|
||||
///
|
||||
/// Cache contract: the deleted path's entry and the parent's stale entry
|
||||
/// are evicted, and the returned parent node is inserted fresh.
|
||||
pub async fn delete_library_node(
|
||||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||
let request = Request::new(DeleteLibraryNodeRequest {
|
||||
path: path.to_string(),
|
||||
});
|
||||
let response = self.client.delete_library_node(request).await?;
|
||||
let Some(parent) = response.into_inner().parent else {
|
||||
return Err(Box::new(RpcClientError::NotFound));
|
||||
};
|
||||
// Drop the deleted node and the stale parent listing; the response
|
||||
// carries the fresh parent to cache instead.
|
||||
self.library_node_cache.remove(path);
|
||||
let parent_path = parent.path.clone();
|
||||
self.library_node_cache.insert(parent_path.clone(), parent);
|
||||
Ok(&self.library_node_cache[&parent_path])
|
||||
}
|
||||
|
||||
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let append_request = Request::new(AppendRequest { paths });
|
||||
self.client.append(append_request).await?;
|
||||
|
|
@ -134,6 +280,27 @@ impl RpcClient {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
|
||||
let save_queue_request = Request::new(SaveQueueRequest { name });
|
||||
self.client.save_queue(save_queue_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn capture_library_node(
|
||||
&mut self,
|
||||
path: String,
|
||||
name: String,
|
||||
download: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let capture_request = Request::new(CaptureLibraryNodeRequest {
|
||||
path,
|
||||
name,
|
||||
download,
|
||||
});
|
||||
self.client.capture_library_node(capture_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let replace_request = Request::new(ReplaceRequest { paths });
|
||||
self.client.replace(replace_request).await?;
|
||||
|
|
@ -196,3 +363,52 @@ impl RpcClient {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_credentials_no_authorization_header_is_sent() {
|
||||
let mut interceptor = AuthInterceptor::new("", "ignored").expect("build");
|
||||
let request = interceptor.call(Request::new(())).expect("intercept");
|
||||
assert!(request.metadata().get("authorization").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_become_a_basic_authorization_header() {
|
||||
let mut interceptor = AuthInterceptor::new("queue-owner", "secret").expect("build");
|
||||
let request = interceptor.call(Request::new(())).expect("intercept");
|
||||
let header = request
|
||||
.metadata()
|
||||
.get("authorization")
|
||||
.expect("header attached")
|
||||
.to_str()
|
||||
.expect("ascii");
|
||||
// base64("queue-owner:secret")
|
||||
assert_eq!(header, "Basic cXVldWUtb3duZXI6c2VjcmV0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_provider_listings_are_never_cached() {
|
||||
// Freshly captured/saved content must show up on the next visit
|
||||
// (a cached /captures hid new captures until a TUI restart).
|
||||
for path in [
|
||||
"/captures",
|
||||
"/captures/faves",
|
||||
"/queues",
|
||||
"/queues/road trip",
|
||||
"/bookmarks/b",
|
||||
"/fs/music",
|
||||
] {
|
||||
assert!(!is_cacheable(path), "{path}");
|
||||
}
|
||||
// Remote providers keep instant back-navigation…
|
||||
for path in ["/", "/tidal", "/tidal/artists/1", "/youtube/search/x"] {
|
||||
assert!(is_cacheable(path), "{path}");
|
||||
}
|
||||
// …and prefix look-alikes are not swept up.
|
||||
assert!(is_cacheable("/fsdy"));
|
||||
assert!(is_cacheable("/queuestore"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/dist
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "cbd-web"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crabidy-core.workspace = true
|
||||
leptos.workspace = true
|
||||
|
||||
# The browser-only half: transport, DOM glue, storage. Kept
|
||||
# target-specific so the native build (which runs the unit tests for
|
||||
# the pure state/keymap logic) stays free of wasm-only crates.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
console_error_panic_hook.workspace = true
|
||||
futures.workspace = true
|
||||
gloo-timers.workspace = true
|
||||
tonic = { workspace = true, features = ["codegen"] }
|
||||
tonic-web-wasm-client.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
wasm-bindgen-futures.workspace = true
|
||||
web-sys = { workspace = true, features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlInputElement",
|
||||
"KeyboardEvent",
|
||||
"Location",
|
||||
"Performance",
|
||||
"Storage",
|
||||
"Window",
|
||||
] }
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# cbd-web — the browser client
|
||||
|
||||
A [Leptos](https://leptos.dev) client-side WASM app with the same
|
||||
functionality as `cbd-tui`, served by `crabidy-server` itself. See
|
||||
`architecture/web-client.md` for the design.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Transport**: gRPC-web (`tonic-web-wasm-client`) over the *same*
|
||||
generated client and proto types the TUI uses (`crabidy-core`). No
|
||||
second API surface — feature parity is structural. The server wraps
|
||||
its existing gRPC service in `tonic-web`, so the browser and the TUI
|
||||
hit identical `/crabidy.v1.CrabidyService/…` paths, and the role
|
||||
auth layer (`architecture/roles-auth.md`) gates both.
|
||||
- **Serving**: the built bundle (`cbd-web/dist`) is embedded into
|
||||
`crabidy-server` at compile time behind the default-on `web-ui`
|
||||
feature and served as the fallback route on port 50051. gRPC and
|
||||
static assets share one origin, so there is no CORS story.
|
||||
- **Local-first**: pure client-side rendering, every asset in the
|
||||
bundle (no CDN, no external fonts), library listings cached in memory
|
||||
like the TUI, credentials and theme in `localStorage`, and the update
|
||||
stream reconnects with backoff when the server disappears. There is
|
||||
no CRDT layer — this is a remote control for one live server state,
|
||||
not an offline-editing app (a deliberate departure from the
|
||||
`web_client_example_workspace` template that informed the toolchain).
|
||||
|
||||
## Functionality
|
||||
|
||||
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click),
|
||||
marks, create/rename/delete nodes (`%`/`e`/`d`, with the capture-delete
|
||||
`y/N` confirmation), bookmark and capture (`w`/`W`, with live progress
|
||||
lines and skipped-track marking), the full queue and playback controls,
|
||||
volume, shuffle/repeat, and a `?` help overlay listing the keys. Keys
|
||||
mirror the TUI; every key also has a clickable control. A light/dark
|
||||
theme follows the OS and can be toggled (persisted). The accent color
|
||||
is the crab orange-red.
|
||||
|
||||
When the server requires credentials, a login form collects the role
|
||||
(`owner` / `queue-owner` / `queue-appender`) and password; they are
|
||||
stored in `localStorage` and sent as the gRPC-web `authorization`
|
||||
header on every request.
|
||||
|
||||
## Building
|
||||
|
||||
The WASM toolchain (trunk, wasm-bindgen, the `wasm32-unknown-unknown`
|
||||
target) is provided by devenv. From the repo root:
|
||||
|
||||
```sh
|
||||
devenv shell -- build-web # release bundle → cbd-web/dist
|
||||
cargo build -p crabidy-server # embeds cbd-web/dist
|
||||
```
|
||||
|
||||
`build-web` clears `RUSTFLAGS` first: the native toolchain sets the
|
||||
mold linker, which `rust-lld` (the wasm linker) cannot parse.
|
||||
|
||||
Building `crabidy-server` without a `cbd-web/dist` present is fine — it
|
||||
embeds a placeholder page telling you to run `build-web`. Build the
|
||||
server `--no-default-features` to drop the web client (and the
|
||||
`tonic-web` layer) entirely.
|
||||
|
||||
## Dev loop
|
||||
|
||||
Run a server, then a live-reloading trunk server that proxies gRPC-web
|
||||
to it:
|
||||
|
||||
```sh
|
||||
cargo run -p crabidy-server # or `cbd`
|
||||
devenv shell -- serve-web # trunk serve on http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
`Trunk.toml` proxies `/crabidy.v1.CrabidyService` to `127.0.0.1:50051`,
|
||||
so the app behaves as if served from the server.
|
||||
|
||||
## Tests
|
||||
|
||||
The DOM-free logic (pane/selection state machines, the keymap, capture
|
||||
progress formatting) lives in `src/state.rs` and `src/keymap.rs` and is
|
||||
unit-tested on the native target:
|
||||
|
||||
```sh
|
||||
cargo test -p cbd-web
|
||||
```
|
||||
|
||||
Components in `src/app.rs` stay thin over that logic. The server-side
|
||||
serving and the gRPC-web + auth routing are tested in `crabidy-server`
|
||||
(`src/web.rs`, `tests/web_server.rs`).
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Build configuration for the wasm bundle (architecture/web-client.md).
|
||||
# `trunk build --release` writes dist/, which crabidy-server embeds on
|
||||
# its next build (feature `web-ui`, default on).
|
||||
|
||||
[build]
|
||||
target = "index.html"
|
||||
release = false
|
||||
|
||||
[serve]
|
||||
# Dev loop: `trunk serve` here + a running crabidy-server; gRPC-web
|
||||
# calls are proxied to it, everything else is served live-reloading.
|
||||
[[proxy]]
|
||||
backend = "http://127.0.0.1:50051"
|
||||
rewrite = "/crabidy.v1.CrabidyService"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>crabidy</title>
|
||||
<link data-trunk rel="css" href="style.css" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,391 @@
|
|||
//! Keyboard bindings — the web port of `cbd-tui/src/app/bindings.rs`,
|
||||
//! keyed by browser `KeyboardEvent` values instead of crossterm codes.
|
||||
//! Deliberate differences: there is no `q` (quit) in a browser tab, and
|
||||
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
|
||||
|
||||
use crate::state::Focus;
|
||||
|
||||
/// Everything a key can trigger. Mirrors the TUI's `Action` list; the
|
||||
/// components translate these into RPCs or local state changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
OpenHelp,
|
||||
CloseHelp,
|
||||
CycleFocus,
|
||||
TogglePlay,
|
||||
RestartTrack,
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
ToggleMute,
|
||||
ToggleShuffle,
|
||||
ToggleRepeat,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
LibraryFirst,
|
||||
LibraryLast,
|
||||
LibraryNext,
|
||||
LibraryPrev,
|
||||
LibraryJumpDown,
|
||||
LibraryJumpUp,
|
||||
LibraryAscend,
|
||||
LibraryDive,
|
||||
LibraryToggleMark,
|
||||
LibraryCaptureNode,
|
||||
LibraryDownloadNode,
|
||||
LibraryCreateNode,
|
||||
LibraryEditNode,
|
||||
LibraryDeleteNode,
|
||||
LibraryQueueAppend,
|
||||
LibraryQueueNext,
|
||||
LibraryQueueReplace,
|
||||
QueueFirst,
|
||||
QueueLast,
|
||||
QueueNext,
|
||||
QueuePrev,
|
||||
QueueJumpDown,
|
||||
QueueJumpUp,
|
||||
QueueSelectCurrent,
|
||||
QueuePlaySelected,
|
||||
QueueInsertHere,
|
||||
QueueRemoveTrack,
|
||||
QueueClearKeepCurrent,
|
||||
QueueClearAll,
|
||||
QueueSaveAs,
|
||||
}
|
||||
|
||||
/// One row of the help overlay: the key label and what it does.
|
||||
pub struct HelpEntry {
|
||||
pub scope: &'static str,
|
||||
pub key: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// The help overlay content, in display order — kept in lockstep with
|
||||
/// [`lookup`] by the unit tests below.
|
||||
pub const HELP: &[HelpEntry] = &[
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "?",
|
||||
description: "Show this help",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Tab",
|
||||
description: "Switch between library and queue",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Space",
|
||||
description: "Play/pause",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "r",
|
||||
description: "Restart current track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "K",
|
||||
description: "Volume up",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "J",
|
||||
description: "Volume down",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "m",
|
||||
description: "Toggle mute",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "z",
|
||||
description: "Toggle shuffle",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "x",
|
||||
description: "Toggle repeat",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Ctrl-n",
|
||||
description: "Next track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Ctrl-p",
|
||||
description: "Previous track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "j / k",
|
||||
description: "Select next / previous item",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "g / G",
|
||||
description: "Select first / last item",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "Ctrl-d / Ctrl-u",
|
||||
description: "Jump 15 items",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "h",
|
||||
description: "Go to parent folder",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "l",
|
||||
description: "Enter selected folder",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "s",
|
||||
description: "Mark/unmark selection",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "w",
|
||||
description: "Save selection as bookmark",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "W",
|
||||
description: "Download selection as capture (can take long; same name resumes)",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "%",
|
||||
description: "Create node here (e.g. search term)",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "e",
|
||||
description: "Rename selected node (e.g. search term)",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "d",
|
||||
description: "Delete selection (captures ask y/N, and delete files)",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "a",
|
||||
description: "Append selection to queue",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "L",
|
||||
description: "Queue selection after current track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
key: "Enter",
|
||||
description: "Replace queue with selection",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "j / k",
|
||||
description: "Select next / previous track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "g / G",
|
||||
description: "Select first / last track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "Ctrl-d / Ctrl-u",
|
||||
description: "Jump 15 tracks",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "o",
|
||||
description: "Select the playing track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "Enter",
|
||||
description: "Play selected track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "p",
|
||||
description: "Insert library selection after this track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "d",
|
||||
description: "Remove selected track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "c",
|
||||
description: "Clear queue except current track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "C",
|
||||
description: "Clear entire queue",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "w",
|
||||
description: "Save queue under a name",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Help",
|
||||
key: "Esc or ?",
|
||||
description: "Close help",
|
||||
},
|
||||
];
|
||||
|
||||
/// Resolves a browser key event to an action, mirroring the TUI's
|
||||
/// `bindings::lookup`: global chords first, then the focused pane's.
|
||||
/// `key` is `KeyboardEvent.key` (case carries shift for letters);
|
||||
/// `ctrl` is `ctrlKey`. While the help overlay is open only its close
|
||||
/// keys resolve; dialogs bypass this entirely (they are modal).
|
||||
pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Action> {
|
||||
if help_open {
|
||||
return matches!(key, "?" | "Escape" | "q").then_some(Action::CloseHelp);
|
||||
}
|
||||
if ctrl {
|
||||
return match key {
|
||||
"n" => Some(Action::NextTrack),
|
||||
"p" => Some(Action::PrevTrack),
|
||||
"d" => Some(match focus {
|
||||
Focus::Library => Action::LibraryJumpDown,
|
||||
Focus::Queue => Action::QueueJumpDown,
|
||||
}),
|
||||
"u" => Some(match focus {
|
||||
Focus::Library => Action::LibraryJumpUp,
|
||||
Focus::Queue => Action::QueueJumpUp,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let global = match key {
|
||||
"?" => Some(Action::OpenHelp),
|
||||
"Tab" => Some(Action::CycleFocus),
|
||||
" " => Some(Action::TogglePlay),
|
||||
"r" => Some(Action::RestartTrack),
|
||||
"K" => Some(Action::VolumeUp),
|
||||
"J" => Some(Action::VolumeDown),
|
||||
"m" => Some(Action::ToggleMute),
|
||||
"z" => Some(Action::ToggleShuffle),
|
||||
"x" => Some(Action::ToggleRepeat),
|
||||
_ => None,
|
||||
};
|
||||
if global.is_some() {
|
||||
return global;
|
||||
}
|
||||
match focus {
|
||||
Focus::Library => match key {
|
||||
"j" | "ArrowDown" => Some(Action::LibraryNext),
|
||||
"k" | "ArrowUp" => Some(Action::LibraryPrev),
|
||||
"g" => Some(Action::LibraryFirst),
|
||||
"G" => Some(Action::LibraryLast),
|
||||
"h" | "ArrowLeft" => Some(Action::LibraryAscend),
|
||||
"l" | "ArrowRight" => Some(Action::LibraryDive),
|
||||
"s" => Some(Action::LibraryToggleMark),
|
||||
"w" => Some(Action::LibraryCaptureNode),
|
||||
"W" => Some(Action::LibraryDownloadNode),
|
||||
"%" => Some(Action::LibraryCreateNode),
|
||||
"e" => Some(Action::LibraryEditNode),
|
||||
"d" => Some(Action::LibraryDeleteNode),
|
||||
"a" => Some(Action::LibraryQueueAppend),
|
||||
"L" => Some(Action::LibraryQueueNext),
|
||||
"Enter" => Some(Action::LibraryQueueReplace),
|
||||
_ => None,
|
||||
},
|
||||
Focus::Queue => match key {
|
||||
"j" | "ArrowDown" => Some(Action::QueueNext),
|
||||
"k" | "ArrowUp" => Some(Action::QueuePrev),
|
||||
"g" => Some(Action::QueueFirst),
|
||||
"G" => Some(Action::QueueLast),
|
||||
"o" => Some(Action::QueueSelectCurrent),
|
||||
"Enter" => Some(Action::QueuePlaySelected),
|
||||
"p" => Some(Action::QueueInsertHere),
|
||||
"d" => Some(Action::QueueRemoveTrack),
|
||||
"c" => Some(Action::QueueClearKeepCurrent),
|
||||
"C" => Some(Action::QueueClearAll),
|
||||
"w" => Some(Action::QueueSaveAs),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pane_focus_decides_shared_chords() {
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, false, "d", false),
|
||||
Some(Action::LibraryDeleteNode)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(Focus::Queue, false, "d", false),
|
||||
Some(Action::QueueRemoveTrack)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, false, "d", true),
|
||||
Some(Action::LibraryJumpDown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globals_win_in_both_panes() {
|
||||
for focus in [Focus::Library, Focus::Queue] {
|
||||
assert_eq!(lookup(focus, false, " ", false), Some(Action::TogglePlay));
|
||||
assert_eq!(
|
||||
lookup(focus, false, "z", false),
|
||||
Some(Action::ToggleShuffle)
|
||||
);
|
||||
assert_eq!(lookup(focus, false, "n", true), Some(Action::NextTrack));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_modal() {
|
||||
assert_eq!(lookup(Focus::Library, true, "j", false), None);
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, true, "Escape", false),
|
||||
Some(Action::CloseHelp)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, true, "?", false),
|
||||
Some(Action::CloseHelp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_alias_the_vim_movement() {
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, false, "ArrowDown", false),
|
||||
Some(Action::LibraryNext)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, false, "ArrowLeft", false),
|
||||
Some(Action::LibraryAscend)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(Focus::Queue, false, "ArrowUp", false),
|
||||
Some(Action::QueuePrev)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_action_reachable_from_help_table() {
|
||||
// The help overlay documents at least every scope we bind.
|
||||
assert!(HELP.iter().any(|h| h.scope == "Global"));
|
||||
assert!(HELP.iter().any(|h| h.scope == "Library"));
|
||||
assert!(HELP.iter().any(|h| h.scope == "Queue"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
//! The crabidy web client (architecture/web-client.md): a Leptos CSR
|
||||
//! app with the same functionality as `cbd-tui`, talking gRPC-web to
|
||||
//! `crabidy-server`, which also serves this bundle.
|
||||
//!
|
||||
//! Only [`rpc`] and [`app`] touch the browser; [`state`] and [`keymap`]
|
||||
//! are pure and unit-tested on the native target (`cargo test -p
|
||||
//! cbd-web`).
|
||||
|
||||
// The pure modules are consumed by the wasm `app` and by the native
|
||||
// tests; the native *binary* target uses neither, so allow dead code
|
||||
// there while keeping the wasm build (where it all runs) fully linted.
|
||||
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
|
||||
mod keymap;
|
||||
mod state;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod app;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod rpc;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
// The native build exists for the unit tests of the pure modules;
|
||||
// the real artifact is the wasm bundle built by trunk.
|
||||
eprintln!(
|
||||
"cbd-web is a browser app: build it with `trunk build` and let crabidy-server serve it"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
//! gRPC-web transport: the same generated `crabidy-core` client the
|
||||
//! TUI uses, over `tonic-web-wasm-client` against the origin that
|
||||
//! served this app (architecture/web-client.md). Credentials, when the
|
||||
//! server requires them, ride as the same `authorization: Basic`
|
||||
//! header the TUI sends; the header value is never logged.
|
||||
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest,
|
||||
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||
ToggleShuffleRequest,
|
||||
};
|
||||
use tonic::{
|
||||
metadata::MetadataValue,
|
||||
service::{interceptor::InterceptedService, Interceptor},
|
||||
Request, Status, Streaming,
|
||||
};
|
||||
use tonic_web_wasm_client::Client as WasmClient;
|
||||
|
||||
/// Attaches the stored `authorization` header to every request; without
|
||||
/// credentials it attaches nothing (open server).
|
||||
#[derive(Clone)]
|
||||
pub struct AuthInterceptor {
|
||||
header: Option<MetadataValue<tonic::metadata::Ascii>>,
|
||||
}
|
||||
|
||||
impl AuthInterceptor {
|
||||
/// `user` empty means "no credentials". The pair is base64-encoded
|
||||
/// exactly like the TUI's interceptor.
|
||||
pub fn new(user: &str, password: &str) -> Option<Self> {
|
||||
if user.is_empty() {
|
||||
return Some(Self { header: None });
|
||||
}
|
||||
let encoded = base64_encode(format!("{user}:{password}").as_bytes());
|
||||
let header = format!("Basic {encoded}").parse().ok()?;
|
||||
Some(Self {
|
||||
header: Some(header),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Interceptor for AuthInterceptor {
|
||||
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
|
||||
if let Some(header) = &self.header {
|
||||
request
|
||||
.metadata_mut()
|
||||
.insert("authorization", header.clone());
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard base64 without pulling the base64 crate into the wasm
|
||||
/// bundle for one call site.
|
||||
fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b = [
|
||||
chunk[0],
|
||||
*chunk.get(1).unwrap_or(&0),
|
||||
*chunk.get(2).unwrap_or(&0),
|
||||
];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
let chars = [
|
||||
ALPHABET[(n >> 18) as usize & 63],
|
||||
ALPHABET[(n >> 12) as usize & 63],
|
||||
ALPHABET[(n >> 6) as usize & 63],
|
||||
ALPHABET[n as usize & 63],
|
||||
];
|
||||
let keep = chunk.len() + 1;
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
out.push(if i < keep { *c as char } else { '=' });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
type Client = CrabidyServiceClient<InterceptedService<WasmClient, AuthInterceptor>>;
|
||||
|
||||
/// The app's connection: thin async wrappers over the generated
|
||||
/// client, mirroring `cbd-tui/src/rpc.rs` (minus its cache — the
|
||||
/// caching rule lives in `state::is_cacheable` and is applied by the
|
||||
/// caller, which owns the reactive store).
|
||||
#[derive(Clone)]
|
||||
pub struct Rpc {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl Rpc {
|
||||
/// Connects to `base_url` (normally the serving origin) with
|
||||
/// optional credentials.
|
||||
pub fn new(base_url: String, user: &str, password: &str) -> Option<Self> {
|
||||
let interceptor = AuthInterceptor::new(user, password)?;
|
||||
let client = CrabidyServiceClient::with_interceptor(WasmClient::new(base_url), interceptor);
|
||||
Some(Self { client })
|
||||
}
|
||||
|
||||
pub async fn update_stream(&mut self) -> Result<Streaming<GetUpdateStreamResponse>, Status> {
|
||||
let response = self
|
||||
.client
|
||||
.get_update_stream(Request::new(GetUpdateStreamRequest {}))
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn init(&mut self) -> Result<crabidy_core::proto::crabidy::InitResponse, Status> {
|
||||
Ok(self
|
||||
.client
|
||||
.init(Request::new(InitRequest {}))
|
||||
.await?
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_library_node(&mut self, path: &str) -> Result<Option<LibraryNode>, Status> {
|
||||
let request = Request::new(GetLibraryNodeRequest {
|
||||
path: path.to_string(),
|
||||
});
|
||||
Ok(self
|
||||
.client
|
||||
.get_library_node(request)
|
||||
.await?
|
||||
.into_inner()
|
||||
.node)
|
||||
}
|
||||
|
||||
pub async fn create_library_node(
|
||||
&mut self,
|
||||
parent_path: &str,
|
||||
title: &str,
|
||||
) -> Result<Option<LibraryNode>, Status> {
|
||||
let request = Request::new(CreateLibraryNodeRequest {
|
||||
parent_path: parent_path.to_string(),
|
||||
title: title.to_string(),
|
||||
});
|
||||
Ok(self
|
||||
.client
|
||||
.create_library_node(request)
|
||||
.await?
|
||||
.into_inner()
|
||||
.node)
|
||||
}
|
||||
|
||||
pub async fn rename_library_node(
|
||||
&mut self,
|
||||
path: &str,
|
||||
new_title: &str,
|
||||
) -> Result<Option<LibraryNode>, Status> {
|
||||
let request = Request::new(RenameLibraryNodeRequest {
|
||||
path: path.to_string(),
|
||||
new_title: new_title.to_string(),
|
||||
});
|
||||
Ok(self
|
||||
.client
|
||||
.rename_library_node(request)
|
||||
.await?
|
||||
.into_inner()
|
||||
.node)
|
||||
}
|
||||
|
||||
pub async fn delete_library_node(&mut self, path: &str) -> Result<Option<LibraryNode>, Status> {
|
||||
let request = Request::new(DeleteLibraryNodeRequest {
|
||||
path: path.to_string(),
|
||||
});
|
||||
Ok(self
|
||||
.client
|
||||
.delete_library_node(request)
|
||||
.await?
|
||||
.into_inner()
|
||||
.parent)
|
||||
}
|
||||
|
||||
pub async fn capture_library_node(
|
||||
&mut self,
|
||||
path: &str,
|
||||
name: &str,
|
||||
download: bool,
|
||||
) -> Result<(), Status> {
|
||||
let request = Request::new(CaptureLibraryNodeRequest {
|
||||
path: path.to_string(),
|
||||
name: name.to_string(),
|
||||
download,
|
||||
});
|
||||
let _ = self.client.capture_library_node(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.replace(Request::new(ReplaceRequest { paths }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.append(Request::new(AppendRequest { paths }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.queue(Request::new(QueueRequest { paths }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_tracks(&mut self, position: u32, paths: Vec<String>) -> Result<(), Status> {
|
||||
let request = Request::new(InsertRequest { position, paths });
|
||||
let _ = self.client.insert(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_tracks(&mut self, positions: Vec<u32>) -> Result<(), Status> {
|
||||
let request = Request::new(RemoveRequest { positions });
|
||||
let _ = self.client.remove(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_queue(&mut self, exclude_current: bool) -> Result<(), Status> {
|
||||
let request = Request::new(ClearQueueRequest { exclude_current });
|
||||
let _ = self.client.clear_queue(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_current(&mut self, position: u32) -> Result<(), Status> {
|
||||
let request = Request::new(SetCurrentRequest { position });
|
||||
let _ = self.client.set_current(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_queue(&mut self, name: &str) -> Result<(), Status> {
|
||||
let request = Request::new(SaveQueueRequest {
|
||||
name: name.to_string(),
|
||||
});
|
||||
let _ = self.client.save_queue(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn toggle_play(&mut self) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.toggle_play(Request::new(TogglePlayRequest {}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart_track(&mut self) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.restart_track(Request::new(RestartTrackRequest {}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn next(&mut self) -> Result<(), Status> {
|
||||
let _ = self.client.next(Request::new(NextRequest {})).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prev(&mut self) -> Result<(), Status> {
|
||||
let _ = self.client.prev(Request::new(PrevRequest {})).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn change_volume(&mut self, delta: f32) -> Result<(), Status> {
|
||||
let request = Request::new(ChangeVolumeRequest { delta });
|
||||
let _ = self.client.change_volume(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn toggle_mute(&mut self) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.toggle_mute(Request::new(ToggleMuteRequest {}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn toggle_shuffle(&mut self) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.toggle_shuffle(Request::new(ToggleShuffleRequest {}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn toggle_repeat(&mut self) -> Result<(), Status> {
|
||||
let _ = self
|
||||
.client
|
||||
.toggle_repeat(Request::new(ToggleRepeatRequest {}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
//! Pure client state — the web port of the TUI's pane logic
|
||||
//! (`cbd-tui/src/app/{library,queue,mod}.rs`), free of DOM and
|
||||
//! transport so it unit-tests on the native target. Components own
|
||||
//! these values inside Leptos signals and call the methods on updates.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crabidy_core::proto::crabidy::{CaptureProgress, LibraryNode, Track};
|
||||
|
||||
/// Which pane has keyboard focus (`Tab` toggles, like the TUI).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
Library,
|
||||
Queue,
|
||||
}
|
||||
|
||||
/// Why the one-line name dialog is open — the web port of the TUI's
|
||||
/// `InputPurpose`, deciding the submit RPC and the dialog label.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NamePurpose {
|
||||
/// `%`: create a child (search term) under the creatable node.
|
||||
Create { parent_path: String },
|
||||
/// `e`: rename the node at `path` (prefilled with its title).
|
||||
Rename { path: String },
|
||||
/// `w` in the queue pane: save the queue under the entered name.
|
||||
SaveQueue,
|
||||
/// `w`/`W` in the library: bookmark or download-capture `path`.
|
||||
Capture { path: String, download: bool },
|
||||
}
|
||||
|
||||
impl NamePurpose {
|
||||
/// The dialog label; capture warns about duration like the TUI.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
NamePurpose::Create { .. } => "new node",
|
||||
NamePurpose::Rename { .. } => "rename",
|
||||
NamePurpose::SaveQueue => "save queue",
|
||||
NamePurpose::Capture {
|
||||
download: false, ..
|
||||
} => "bookmark",
|
||||
NamePurpose::Capture { download: true, .. } => "capture (slow, resumable)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A modal dialog. At most one is open; while one is open, keys go to
|
||||
/// it (the keymap is bypassed, mirroring the TUI's modal overlays).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Dialog {
|
||||
/// Text input with a purpose-dependent submit.
|
||||
Name {
|
||||
purpose: NamePurpose,
|
||||
buffer: String,
|
||||
},
|
||||
/// The capture-delete confirmation (architecture/capture-deletion.md).
|
||||
ConfirmDelete { path: String, title: String },
|
||||
/// Credentials form, shown on `UNAUTHENTICATED` responses.
|
||||
Login,
|
||||
/// The `?` key binding overlay.
|
||||
Help,
|
||||
}
|
||||
|
||||
/// Whether deleting `path` needs the y/N confirmation — same rule as
|
||||
/// the TUI: captures hold downloaded audio, everything else deletable
|
||||
/// is cheap to recreate.
|
||||
pub fn delete_needs_confirmation(path: &str) -> bool {
|
||||
path == "/captures" || path.starts_with("/captures/")
|
||||
}
|
||||
|
||||
/// Whether a library listing may be cached client-side — same rule as
|
||||
/// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate
|
||||
/// behind the client's back and are cheap to re-list.
|
||||
pub fn is_cacheable(path: &str) -> bool {
|
||||
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"];
|
||||
!MUTABLE_ROOTS.iter().any(|root| {
|
||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UiItemKind {
|
||||
Track,
|
||||
Node,
|
||||
}
|
||||
|
||||
/// One row of the library pane — the TUI's `UiItem`, unchanged.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UiItem {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub kind: UiItemKind,
|
||||
pub marked: bool,
|
||||
pub is_queable: bool,
|
||||
pub is_creatable: bool,
|
||||
pub is_editable: bool,
|
||||
pub is_deletable: bool,
|
||||
pub is_downloadable: bool,
|
||||
pub is_skipped: bool,
|
||||
}
|
||||
|
||||
/// The library pane: current listing, cursor, marks, and per-path
|
||||
/// cursor memory (going back re-selects where you were).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LibraryPane {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub parent: Option<String>,
|
||||
pub is_creatable: bool,
|
||||
pub items: Vec<UiItem>,
|
||||
pub selected: usize,
|
||||
positions: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl LibraryPane {
|
||||
/// Applies a fresh listing. Mirrors the TUI: an empty, non-creatable
|
||||
/// node is not entered (nothing to show, nothing to create), and
|
||||
/// tracks list before child nodes.
|
||||
pub fn update(&mut self, node: &LibraryNode) {
|
||||
if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.positions.insert(self.path.clone(), self.selected);
|
||||
self.path = node.path.clone();
|
||||
self.title = node.title.clone();
|
||||
self.parent = node.parent.clone();
|
||||
self.is_creatable = node.is_creatable;
|
||||
self.items = node
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| UiItem {
|
||||
path: t.path.clone(),
|
||||
title: format!("{} - {}", t.artist, t.title),
|
||||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
is_queable: true,
|
||||
is_creatable: false,
|
||||
is_editable: false,
|
||||
// Tracks inherit their node's blessing, like the TUI.
|
||||
is_deletable: node.tracks_deletable,
|
||||
is_downloadable: node.is_downloadable,
|
||||
is_skipped: t.is_skipped,
|
||||
})
|
||||
.chain(node.children.iter().map(|c| UiItem {
|
||||
path: c.path.clone(),
|
||||
title: c.title.clone(),
|
||||
kind: UiItemKind::Node,
|
||||
marked: false,
|
||||
is_queable: c.is_queable,
|
||||
is_creatable: c.is_creatable,
|
||||
is_editable: c.is_editable,
|
||||
is_deletable: c.is_deletable,
|
||||
is_downloadable: c.is_downloadable,
|
||||
is_skipped: false,
|
||||
}))
|
||||
.collect();
|
||||
self.selected = self
|
||||
.positions
|
||||
.get(&self.path)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(self.items.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn selected_item(&self) -> Option<&UiItem> {
|
||||
self.items.get(self.selected)
|
||||
}
|
||||
|
||||
/// Cursor movement; `delta` may over/undershoot (jump keys).
|
||||
pub fn select_by(&mut self, delta: isize) {
|
||||
if self.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let last = self.items.len() - 1;
|
||||
self.selected = self.selected.saturating_add_signed(delta).min(last);
|
||||
}
|
||||
|
||||
pub fn select_first(&mut self) {
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
pub fn select_last(&mut self) {
|
||||
self.selected = self.items.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn select(&mut self, index: usize) {
|
||||
if index < self.items.len() {
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Space`: toggles the mark of the selection (queueable items only).
|
||||
pub fn toggle_mark(&mut self) {
|
||||
if let Some(item) = self.items.get_mut(self.selected) {
|
||||
if item.is_queable {
|
||||
item.marked = !item.marked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_marks(&mut self) {
|
||||
for item in &mut self.items {
|
||||
item.marked = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths a queue operation ships: all marked items, or the
|
||||
/// bare queueable selection — exactly the TUI's `get_selected`.
|
||||
pub fn queueable_selection(&self) -> Option<Vec<String>> {
|
||||
if self.items.iter().any(|i| i.marked) {
|
||||
return Some(
|
||||
self.items
|
||||
.iter()
|
||||
.filter(|i| i.marked)
|
||||
.map(|i| i.path.clone())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let item = self.selected_item()?;
|
||||
item.is_queable.then(|| vec![item.path.clone()])
|
||||
}
|
||||
|
||||
pub fn selected_editable(&self) -> Option<(String, String)> {
|
||||
let item = self.selected_item()?;
|
||||
item.is_editable
|
||||
.then(|| (item.path.clone(), item.title.clone()))
|
||||
}
|
||||
|
||||
pub fn selected_deletable(&self) -> Option<(String, String)> {
|
||||
let item = self.selected_item()?;
|
||||
item.is_deletable
|
||||
.then(|| (item.path.clone(), item.title.clone()))
|
||||
}
|
||||
|
||||
pub fn selected_queueable(&self) -> Option<(String, String)> {
|
||||
let item = self.selected_item()?;
|
||||
item.is_queable
|
||||
.then(|| (item.path.clone(), item.title.clone()))
|
||||
}
|
||||
|
||||
pub fn selected_downloadable(&self) -> Option<(String, String)> {
|
||||
let item = self.selected_item()?;
|
||||
(item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The queue pane cursor. The queue itself (tracks, current position,
|
||||
/// play state) lives in signals fed by the update stream; this only
|
||||
/// tracks the selection.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct QueueCursor {
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
impl QueueCursor {
|
||||
pub fn select_by(&mut self, delta: isize, len: usize) {
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
self.selected = self.selected.saturating_add_signed(delta).min(len - 1);
|
||||
}
|
||||
|
||||
pub fn clamp(&mut self, len: usize) {
|
||||
self.selected = self.selected.min(len.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a finished capture's line lingers, in milliseconds —
|
||||
/// the TUI's `CaptureBoard` with an injected clock (the browser has
|
||||
/// `performance.now()`, tests pass plain numbers).
|
||||
const CAPTURE_DONE_LINGER_MS: f64 = 5_000.0;
|
||||
const CAPTURE_ERROR_LINGER_MS: f64 = 10_000.0;
|
||||
|
||||
struct CaptureEntry {
|
||||
progress: CaptureProgress,
|
||||
finished_at: Option<f64>,
|
||||
}
|
||||
|
||||
/// Live capture progress lines, keyed by capture name.
|
||||
#[derive(Default)]
|
||||
pub struct CaptureBoard {
|
||||
entries: Vec<CaptureEntry>,
|
||||
}
|
||||
|
||||
impl CaptureBoard {
|
||||
/// Applies one stream update at time `now_ms`.
|
||||
pub fn apply(&mut self, progress: CaptureProgress, now_ms: f64) {
|
||||
let finished_at = progress.finished.then_some(now_ms);
|
||||
let entry = CaptureEntry {
|
||||
progress,
|
||||
finished_at,
|
||||
};
|
||||
match self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.progress.name == entry.progress.name)
|
||||
{
|
||||
Some(existing) => *existing = entry,
|
||||
None => self.entries.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines to render at `now_ms`, oldest first, with an is-error
|
||||
/// flag; expired finished entries are dropped.
|
||||
pub fn lines(&mut self, now_ms: f64) -> Vec<(String, bool)> {
|
||||
self.entries.retain(|e| match e.finished_at {
|
||||
None => true,
|
||||
Some(at) if e.progress.error.is_empty() => now_ms - at < CAPTURE_DONE_LINGER_MS,
|
||||
Some(at) => now_ms - at < CAPTURE_ERROR_LINGER_MS,
|
||||
});
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|e| (Self::line(&e.progress), !e.progress.error.is_empty()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One entry's display line — character for character the TUI's.
|
||||
fn line(p: &CaptureProgress) -> String {
|
||||
let verb = if p.download {
|
||||
("capturing", "captured", "capture")
|
||||
} else {
|
||||
("bookmarking", "bookmarked", "bookmark")
|
||||
};
|
||||
let skipped = if p.tracks_skipped > 0 {
|
||||
format!(" ({} skipped)", p.tracks_skipped)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !p.finished {
|
||||
let total = if p.tracks_total > 0 {
|
||||
p.tracks_total.to_string()
|
||||
} else {
|
||||
"?".to_string()
|
||||
};
|
||||
format!("{} {} {}/{total}{skipped}", verb.0, p.name, p.tracks_done)
|
||||
} else if p.error.is_empty() {
|
||||
format!("{} {}: {} tracks{skipped}", verb.1, p.name, p.tracks_done)
|
||||
} else {
|
||||
format!("{} {} failed: {}", verb.2, p.name, p.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `mm:ss` for progress and duration displays.
|
||||
pub fn format_seconds(total: u32) -> String {
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
/// The now-playing line for a track, `artist - title` falling back to
|
||||
/// the path's last segment for artistless tracks.
|
||||
pub fn track_label(track: &Track) -> String {
|
||||
if track.artist.is_empty() {
|
||||
track.title.clone()
|
||||
} else {
|
||||
format!("{} - {}", track.artist, track.title)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crabidy_core::proto::crabidy::LibraryNodeChild;
|
||||
|
||||
fn node(path: &str, tracks: usize, children: usize) -> LibraryNode {
|
||||
LibraryNode {
|
||||
path: path.to_string(),
|
||||
title: path.trim_start_matches('/').to_string(),
|
||||
children: (0..children)
|
||||
.map(|i| LibraryNodeChild::new(format!("{path}/c{i}"), format!("c{i}"), true))
|
||||
.collect(),
|
||||
parent: Some("/".to_string()),
|
||||
tracks: (0..tracks)
|
||||
.map(|i| Track {
|
||||
path: format!("{path}/t{i}"),
|
||||
artist: "artist".to_string(),
|
||||
title: format!("t{i}"),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
})
|
||||
.collect(),
|
||||
is_queable: true,
|
||||
is_creatable: false,
|
||||
is_downloadable: false,
|
||||
tracks_deletable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_order_tracks_before_children_and_remember_positions() {
|
||||
let mut pane = LibraryPane::default();
|
||||
pane.update(&node("/a", 2, 2));
|
||||
assert_eq!(pane.items.len(), 4);
|
||||
assert_eq!(pane.items[0].kind, UiItemKind::Track);
|
||||
assert_eq!(pane.items[2].kind, UiItemKind::Node);
|
||||
|
||||
pane.select_by(3);
|
||||
assert_eq!(pane.selected, 3, "clamped to the last item");
|
||||
pane.update(&node("/a/c1", 1, 0));
|
||||
assert_eq!(pane.selected, 0, "fresh node starts at the top");
|
||||
pane.update(&node("/a", 2, 2));
|
||||
assert_eq!(pane.selected, 3, "back-navigation restores the cursor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_non_creatable_nodes_are_not_entered() {
|
||||
let mut pane = LibraryPane::default();
|
||||
pane.update(&node("/a", 1, 0));
|
||||
let empty = node("/a/empty", 0, 0);
|
||||
pane.update(&empty);
|
||||
assert_eq!(pane.path, "/a", "listing unchanged");
|
||||
|
||||
let mut creatable = node("/tidal/search", 0, 0);
|
||||
creatable.is_creatable = true;
|
||||
pane.update(&creatable);
|
||||
assert_eq!(pane.path, "/tidal/search", "creatable nodes open empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_collect_and_bare_selection_falls_back() {
|
||||
let mut pane = LibraryPane::default();
|
||||
pane.update(&node("/a", 2, 1));
|
||||
assert_eq!(
|
||||
pane.queueable_selection(),
|
||||
Some(vec!["/a/t0".to_string()]),
|
||||
"bare selection"
|
||||
);
|
||||
pane.toggle_mark();
|
||||
pane.select_by(2);
|
||||
pane.toggle_mark();
|
||||
assert_eq!(
|
||||
pane.queueable_selection(),
|
||||
Some(vec!["/a/t0".to_string(), "/a/c0".to_string()]),
|
||||
"marks win over the cursor"
|
||||
);
|
||||
pane.remove_marks();
|
||||
assert!(pane.items.iter().all(|i| !i.marked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_and_deletable_flags_reach_the_items() {
|
||||
let mut listing = node("/captures/mix", 1, 0);
|
||||
listing.tracks_deletable = true;
|
||||
listing.tracks[0].is_skipped = true;
|
||||
let mut pane = LibraryPane::default();
|
||||
pane.update(&listing);
|
||||
assert!(pane.items[0].is_skipped);
|
||||
assert!(pane.items[0].is_deletable, "tracks inherit the node flag");
|
||||
assert_eq!(
|
||||
pane.selected_deletable(),
|
||||
Some(("/captures/mix/t0".to_string(), "artist - t0".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_deletes_need_confirmation_cheap_deletes_do_not() {
|
||||
assert!(delete_needs_confirmation("/captures/mix"));
|
||||
assert!(delete_needs_confirmation("/captures/mix/a.cbd-track.toml"));
|
||||
assert!(!delete_needs_confirmation("/queues/roadtrip"));
|
||||
assert!(!delete_needs_confirmation("/tidal/search/abba"));
|
||||
assert!(!delete_needs_confirmation("/capturesque"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_roots_are_never_cacheable() {
|
||||
for path in ["/captures", "/queues/x", "/bookmarks", "/fs/music"] {
|
||||
assert!(!is_cacheable(path), "{path}");
|
||||
}
|
||||
for path in ["/tidal/playlists", "/youtube/search", "/capturesque"] {
|
||||
assert!(is_cacheable(path), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_board_lines_match_the_tui_and_expire() {
|
||||
let mut board = CaptureBoard::default();
|
||||
board.apply(
|
||||
CaptureProgress {
|
||||
name: "mix".into(),
|
||||
download: true,
|
||||
tracks_done: 3,
|
||||
tracks_total: 9,
|
||||
tracks_skipped: 1,
|
||||
finished: false,
|
||||
error: String::new(),
|
||||
},
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(
|
||||
board.lines(0.0),
|
||||
vec![("capturing mix 3/9 (1 skipped)".to_string(), false)]
|
||||
);
|
||||
board.apply(
|
||||
CaptureProgress {
|
||||
name: "mix".into(),
|
||||
download: true,
|
||||
tracks_done: 9,
|
||||
tracks_total: 9,
|
||||
tracks_skipped: 1,
|
||||
finished: true,
|
||||
error: String::new(),
|
||||
},
|
||||
1_000.0,
|
||||
);
|
||||
assert_eq!(
|
||||
board.lines(1_000.0),
|
||||
vec![("captured mix: 9 tracks (1 skipped)".to_string(), false)]
|
||||
);
|
||||
assert!(board.lines(7_000.0).is_empty(), "done lines expire");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_formatting_is_mm_ss() {
|
||||
assert_eq!(format_seconds(0), "0:00");
|
||||
assert_eq!(format_seconds(61), "1:01");
|
||||
assert_eq!(format_seconds(3599), "59:59");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
/* crabidy web client — pure modern CSS (architecture/web-client.md).
|
||||
One accent variable (crab orange-red), light and dark themes via
|
||||
color-scheme + light-dark(); the theme toggle stamps data-theme on
|
||||
<html>, otherwise the OS decides. No frameworks, no external
|
||||
requests — everything ships in the bundle. */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* The crab. Every accent tone derives from this one value. */
|
||||
--accent: oklch(0.62 0.19 35);
|
||||
--accent-strong: color-mix(in oklch, var(--accent) 85%, black);
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
--on-accent: oklch(0.99 0.005 60);
|
||||
|
||||
--bg: light-dark(oklch(0.98 0.005 60), oklch(0.17 0.01 260));
|
||||
--bg-raised: light-dark(oklch(1 0 0), oklch(0.21 0.012 260));
|
||||
--fg: light-dark(oklch(0.25 0.015 260), oklch(0.92 0.005 60));
|
||||
--fg-dim: light-dark(oklch(0.52 0.012 260), oklch(0.68 0.008 60));
|
||||
--danger: light-dark(oklch(0.54 0.2 25), oklch(0.68 0.19 25));
|
||||
--border: color-mix(in oklch, var(--fg) 14%, transparent);
|
||||
--shadow: 0 8px 32px light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.55));
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.45 system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: var(--danger);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
&.ghost.danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in oklch, var(--danger) 15%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- frame ---------------------------------------------------------- */
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
block-size: 100dvh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-block-end: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
|
||||
& .brand {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
& .conn {
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg-dim);
|
||||
|
||||
&.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
& .topbar-actions {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- panes ----------------------------------------------------------- */
|
||||
|
||||
.panes {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-block-size: 0;
|
||||
border-inline-end: 1px solid var(--border);
|
||||
/* The focused pane shows it like the TUI's highlighted border. */
|
||||
box-shadow: inset 0 2px 0 transparent;
|
||||
|
||||
&:last-child {
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-block-end: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
|
||||
& .path {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
min-block-size: 0;
|
||||
|
||||
& li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.28rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-inline-start: 3px solid transparent;
|
||||
|
||||
& .title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .badge {
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--accent-soft);
|
||||
border-inline-start-color: var(--accent);
|
||||
}
|
||||
|
||||
&.marked .title {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
content: "* ";
|
||||
}
|
||||
}
|
||||
|
||||
/* Skipped tracks carry no audio (incremental captures). */
|
||||
&.skipped .title {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&.node .title {
|
||||
color: color-mix(in oklch, var(--fg) 80%, var(--accent));
|
||||
}
|
||||
|
||||
&.current .title {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
& .row-action {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover .row-action,
|
||||
&.selected .row-action {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.capture-lines {
|
||||
padding: 0.2rem 0.75rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg-dim);
|
||||
|
||||
& .capture-line.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- transport -------------------------------------------------------- */
|
||||
|
||||
.transport {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-block-start: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
|
||||
& .controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
|
||||
& .big {
|
||||
font-size: 1.3rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
& .now-playing {
|
||||
min-inline-size: 0;
|
||||
|
||||
& .np-title {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Frequency-spectrum bars (architecture/spectrum.md): a row of
|
||||
accent-colored columns whose heights track the streamed bins. */
|
||||
& .spectrum {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
block-size: 1.5rem;
|
||||
margin-block-end: 0.15rem;
|
||||
|
||||
& .bar {
|
||||
flex: 1;
|
||||
min-block-size: 1px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
transition: height 0.08s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
& .time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
& .gauge {
|
||||
flex: 1;
|
||||
block-size: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-soft);
|
||||
overflow: hidden;
|
||||
|
||||
& .gauge-fill {
|
||||
block-size: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
& input[type="range"] {
|
||||
inline-size: 7rem;
|
||||
accent-color: var(--accent);
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- overlays ---------------------------------------------------------- */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-inline-size: min(26rem, 90vw);
|
||||
max-block-size: 85dvh;
|
||||
overflow-y: auto;
|
||||
padding: 1.1rem 1.3rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-raised);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
& label {
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
& .dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&.danger-dialog {
|
||||
border-inline-start: 4px solid var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.help {
|
||||
min-inline-size: min(52rem, 94vw);
|
||||
|
||||
& h2 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
& .help-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: 0.5rem 2rem;
|
||||
|
||||
& h3 {
|
||||
margin: 0.4rem 0 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
& table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
|
||||
& td {
|
||||
padding: 0.12rem 0.4rem 0.12rem 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
& .key {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
inset-block-end: 4.5rem;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--danger);
|
||||
color: var(--on-accent);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ---- phone ------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* One pane at a time; Tab (or tapping a pane edge) switches — the
|
||||
unfocused pane collapses to a slim strip acting as its tab. */
|
||||
.panes {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
.pane:not(.focused) {
|
||||
grid-template-rows: auto;
|
||||
max-block-size: 2.4rem;
|
||||
overflow: hidden;
|
||||
border-block-start: 1px solid var(--border);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.transport {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
|
||||
& .volume {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "cbd"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cbd-tui.workspace = true
|
||||
crabidy-core.workspace = true
|
||||
crabidy-server.workspace = true
|
||||
dirs.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing.workspace = true
|
||||
tracing-appender.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
//! `cbd`: server and TUI bundled into one binary
|
||||
//! (see `architecture/cbd-bundle.md`).
|
||||
//!
|
||||
//! Starting `cbd` starts the crabidy server in-process, waits for it to
|
||||
//! accept connections, and runs the TUI against it — the same configs,
|
||||
//! the same localhost gRPC wire as the standalone pair. If a server is
|
||||
//! already listening (a standalone `crabidy-server`), `cbd` adopts it
|
||||
//! instead of failing. Quitting the TUI ends the process, and with it
|
||||
//! the in-process server; the current queue is persisted continuously,
|
||||
//! so the next start restores it.
|
||||
|
||||
use std::error::Error;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use cbd_tui::config::Config;
|
||||
use tracing::{info, warn};
|
||||
|
||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
|
||||
/// How long to wait for the server socket before giving up. Generous:
|
||||
/// the first server start may run a provider login flow.
|
||||
const READINESS_ATTEMPTS: u32 = 120;
|
||||
const READINESS_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Both halves share one file-based subscriber: the terminal belongs
|
||||
// to the TUI, so the server's usual stderr logging would corrupt it.
|
||||
let _log_guard = init_tracing();
|
||||
// `cbd` reads its OWN config (`cbd.toml`), separate from the
|
||||
// standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on
|
||||
// one machine — `cbd` self-contained against its in-process server,
|
||||
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
|
||||
// shared `address` would force one to follow the other. `cbd`
|
||||
// defaults to localhost, which matches its embedded server.
|
||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd.toml"));
|
||||
|
||||
let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
|
||||
let mut server = tokio::spawn(crabidy_server::serve(addr));
|
||||
wait_for_server(
|
||||
&config.server.address,
|
||||
&mut server,
|
||||
READINESS_ATTEMPTS,
|
||||
READINESS_DELAY,
|
||||
)
|
||||
.await?;
|
||||
|
||||
cbd_tui::run(config).await
|
||||
}
|
||||
|
||||
/// Waits until something accepts TCP connections on the TUI's configured
|
||||
/// server address (scheme stripped): the in-process server coming up, or
|
||||
/// an already-running standalone one (in which case our `serve` fails
|
||||
/// with the port taken and is deliberately ignored). Fails when the
|
||||
/// in-process server dies while nothing is listening, or after
|
||||
/// `attempts` polls.
|
||||
async fn wait_for_server(
|
||||
address: &str,
|
||||
server: &mut tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
|
||||
attempts: u32,
|
||||
delay: Duration,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let host_port = address
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://")
|
||||
.trim_end_matches('/');
|
||||
for _ in 0..attempts {
|
||||
if tokio::net::TcpStream::connect(host_port).await.is_ok() {
|
||||
if server.is_finished() {
|
||||
warn!("a server is already listening; connecting to it instead");
|
||||
} else {
|
||||
info!(address, "server is ready");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if server.is_finished() {
|
||||
// Nothing listening and our server is gone: a real failure
|
||||
// (provider init, bad address), not an occupied port.
|
||||
return match server.await {
|
||||
Ok(Ok(())) => Err("the server exited before becoming ready".into()),
|
||||
Ok(Err(err)) => Err(err.to_string().into()),
|
||||
Err(err) => Err(err.to_string().into()),
|
||||
};
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(format!("no server reachable at {host_port} after {attempts} attempts").into())
|
||||
}
|
||||
|
||||
/// Logs to a file (`crabidy/cbd.log` in the state dir), like `cbd-tui` —
|
||||
/// but with the server crates' filter, since they run in-process here.
|
||||
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
|
||||
let log_dir = dirs::state_dir()
|
||||
.or_else(dirs::cache_dir)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("crabidy");
|
||||
if let Err(err) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!(
|
||||
"could not create log directory {}: {err}",
|
||||
log_dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new(
|
||||
"info,cbd=debug,cbd_tui=debug,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug",
|
||||
)
|
||||
});
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true),
|
||||
)
|
||||
.init();
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A server task that never finishes, standing in for a healthy
|
||||
/// in-process server still starting up.
|
||||
fn pending_server() -> tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>> {
|
||||
tokio::spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_polls_until_the_socket_accepts() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
// The scheme prefix must be stripped like the TUI config's URL.
|
||||
let address = format!("http://{addr}/");
|
||||
let mut server = pending_server();
|
||||
wait_for_server(&address, &mut server, 10, Duration::from_millis(10))
|
||||
.await
|
||||
.expect("socket accepts");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_gives_up_and_reports_a_dead_server() {
|
||||
// Nothing listens on this address (bound, then dropped).
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
drop(listener);
|
||||
|
||||
// A dead server task with nothing listening is a real failure.
|
||||
let mut dead: tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>> =
|
||||
tokio::spawn(async { Err("provider init failed".into()) });
|
||||
let err = wait_for_server(
|
||||
&format!("http://{addr}"),
|
||||
&mut dead,
|
||||
10,
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.await
|
||||
.expect_err("dead server surfaces");
|
||||
assert!(err.to_string().contains("provider init failed"));
|
||||
|
||||
// A healthy-but-slow server just runs out of attempts.
|
||||
let mut server = pending_server();
|
||||
let err = wait_for_server(
|
||||
&format!("http://{addr}"),
|
||||
&mut server,
|
||||
3,
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.await
|
||||
.expect_err("gives up eventually");
|
||||
assert!(err.to_string().contains("after 3 attempts"));
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,28 @@ edition.workspace = true
|
|||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
clap-serde-derive.workspace = true
|
||||
dirs.workspace = true
|
||||
flume.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
prost.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
tonic.workspace = true
|
||||
# Codegen only: the generated client/server stubs need no transport,
|
||||
# which keeps this crate building for wasm32 (cbd-web, see
|
||||
# architecture/web-client.md). Native binaries pull the full tonic
|
||||
# through their own dependency edges.
|
||||
tonic = { workspace = true, default-features = false, features = ["codegen"] }
|
||||
tracing.workspace = true
|
||||
tonic-prost.workspace = true
|
||||
|
||||
# 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]
|
||||
tonic-prost-build.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_prost_build::compile_protos("crabidy/v1/crabidy.proto")?;
|
||||
// No `connect()` convenience impl: it hardcodes tonic::transport,
|
||||
// which the wasm build of this crate deliberately lacks
|
||||
// (architecture/web-client.md). Clients construct their channel
|
||||
// (native: Endpoint, browser: tonic-web-wasm-client) themselves.
|
||||
tonic_prost_build::configure()
|
||||
.build_transport(false)
|
||||
.compile_protos(&["crabidy/v1/crabidy.proto"], &["."])?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,32 @@ service CrabidyService {
|
|||
|
||||
// Library
|
||||
rpc GetLibraryNode(GetLibraryNodeRequest) returns (GetLibraryNodeResponse);
|
||||
// Creates a child node under a creatable parent (LibraryNode.is_creatable).
|
||||
// What creation means is provider-defined; under /tidal/search the title is
|
||||
// a search term and the created node holds its results. Idempotent: an
|
||||
// existing title returns the existing node.
|
||||
rpc CreateLibraryNode(CreateLibraryNodeRequest) returns (CreateLibraryNodeResponse);
|
||||
// Renames a node whose listing entry sets is_editable. For a search term
|
||||
// the title is the query, so a rename re-runs the search; the node's path
|
||||
// changes with the title. Renaming onto an existing sibling title merges
|
||||
// with it. Returns the renamed node at its new path.
|
||||
rpc RenameLibraryNode(RenameLibraryNodeRequest) returns (RenameLibraryNodeResponse);
|
||||
// Deletes a node whose listing entry sets is_deletable, or a track
|
||||
// whose parent node sets tracks_deletable. On filesystem-backed stores
|
||||
// this removes the data from disk: a folder is deleted recursively, a
|
||||
// track loses its metadata file and its local audio. Idempotent:
|
||||
// deleting an already-gone node succeeds. Returns the refreshed parent.
|
||||
rpc DeleteLibraryNode(DeleteLibraryNodeRequest) returns (DeleteLibraryNodeResponse);
|
||||
// Captures the queueable subtree at `path` as the bookmark `name`: a
|
||||
// structure-preserving snapshot under /bookmarks/<name> (folders per
|
||||
// child node, link track files per track). Overwrites an existing
|
||||
// bookmark of the same name; a *download* capture instead merges into an
|
||||
// existing /captures/<name>, resuming what is not yet downloaded.
|
||||
// Returns once the capture is accepted (name, store, and download
|
||||
// blessing validated); the walk runs server-side and reports through
|
||||
// CaptureProgress updates on GetUpdateStream, ending in one event with
|
||||
// `finished` set (and `error` on failure).
|
||||
rpc CaptureLibraryNode(CaptureLibraryNodeRequest) returns (CaptureLibraryNodeResponse);
|
||||
|
||||
// Queue
|
||||
rpc Queue(QueueRequest) returns (QueueResponse);
|
||||
|
|
@ -51,6 +77,50 @@ message GetLibraryNodeResponse {
|
|||
LibraryNode node = 1;
|
||||
}
|
||||
|
||||
message CreateLibraryNodeRequest {
|
||||
// Path of the creatable parent, e.g. /tidal/search.
|
||||
string parent_path = 1;
|
||||
// Human-entered name; becomes the node title. The path segment is a
|
||||
// percent-encoded form of it, chosen by the provider.
|
||||
string title = 2;
|
||||
}
|
||||
message CreateLibraryNodeResponse {
|
||||
LibraryNode node = 1;
|
||||
}
|
||||
|
||||
message RenameLibraryNodeRequest {
|
||||
// Path of the node to rename.
|
||||
string path = 1;
|
||||
// New human-entered title; the provider derives the new path segment.
|
||||
string new_title = 2;
|
||||
}
|
||||
message RenameLibraryNodeResponse {
|
||||
// The renamed node, at its (possibly changed) path.
|
||||
LibraryNode node = 1;
|
||||
}
|
||||
|
||||
message DeleteLibraryNodeRequest {
|
||||
// Path of the node to delete.
|
||||
string path = 1;
|
||||
}
|
||||
message DeleteLibraryNodeResponse {
|
||||
// The parent node with the deleted child gone — what a client should
|
||||
// display after the delete, without a follow-up GetLibraryNode.
|
||||
LibraryNode parent = 1;
|
||||
}
|
||||
|
||||
message CaptureLibraryNodeRequest {
|
||||
// Path of the queueable node (or track) to capture.
|
||||
string path = 1;
|
||||
// Capture name; becomes the top-level folder under /bookmarks (or
|
||||
// /captures when download is set).
|
||||
string name = 2;
|
||||
// Download every track's audio into the capture instead of writing
|
||||
// link files; the source node must set is_downloadable.
|
||||
bool download = 3;
|
||||
}
|
||||
message CaptureLibraryNodeResponse {}
|
||||
|
||||
// Queue
|
||||
message QueueRequest {
|
||||
repeated string paths = 1;
|
||||
|
|
@ -110,9 +180,43 @@ message GetUpdateStreamResponse {
|
|||
float volume = 5;
|
||||
bool mute = 6;
|
||||
TrackPosition position = 7;
|
||||
CaptureProgress capture_progress = 8;
|
||||
SpectrumFrame spectrum = 9;
|
||||
}
|
||||
}
|
||||
|
||||
// One frame of the audio frequency spectrum (architecture/spectrum.md).
|
||||
// Broadcast by the server at a low frame rate while audio is playing; an
|
||||
// all-zero frame signals silence. Clients render `bins` as bars; they do
|
||||
// no signal processing themselves.
|
||||
message SpectrumFrame {
|
||||
// Normalized magnitudes in [0, 1], low frequency first, log-spaced.
|
||||
// The count is the server's bin resolution (a handful of bars).
|
||||
repeated float bins = 1;
|
||||
}
|
||||
|
||||
// Progress of a running capture (CaptureLibraryNode). Broadcast after each
|
||||
// processed track; exactly one event per capture sets `finished` (with
|
||||
// `error` on failure).
|
||||
message CaptureProgress {
|
||||
// The capture (or bookmark) name the user chose.
|
||||
string name = 1;
|
||||
// True for a download capture (W), false for a bookmark (w).
|
||||
bool download = 2;
|
||||
// Tracks settled so far (reused, downloaded, linked, or recorded as
|
||||
// skipped) — reaches tracks_total on success.
|
||||
uint32 tracks_done = 3;
|
||||
// Total tracks discovered by the enumeration; 0 until it completes.
|
||||
uint32 tracks_total = 4;
|
||||
// Of the settled tracks, how many were recorded as skipped
|
||||
// (uncapturable source) this run.
|
||||
uint32 tracks_skipped = 5;
|
||||
// Terminal event: the capture is over.
|
||||
bool finished = 6;
|
||||
// Why it failed; empty on success. Never carries URLs or file contents.
|
||||
string error = 7;
|
||||
}
|
||||
|
||||
// Playback
|
||||
message TogglePlayRequest {}
|
||||
message TogglePlayResponse {}
|
||||
|
|
@ -142,6 +246,14 @@ message LibraryNodeChild {
|
|||
string path = 1;
|
||||
string title = 2;
|
||||
bool is_queable = 3;
|
||||
// Children may be created under this node (see CreateLibraryNode).
|
||||
bool is_creatable = 4;
|
||||
// This node may be renamed (see RenameLibraryNode).
|
||||
bool is_editable = 5;
|
||||
// This node may be deleted (see DeleteLibraryNode).
|
||||
bool is_deletable = 6;
|
||||
// This node allows download captures (CaptureLibraryNode with download).
|
||||
bool is_downloadable = 7;
|
||||
}
|
||||
|
||||
message QueueModifiers {
|
||||
|
|
@ -154,6 +266,10 @@ message Queue {
|
|||
uint32 current_position = 2;
|
||||
// Without album
|
||||
repeated Track tracks = 3;
|
||||
// True while the server is still resolving queued paths into tracks: more
|
||||
// tracks will arrive in subsequent Queue updates. Clients may show a
|
||||
// loading indicator until an update carries resolving = false.
|
||||
bool resolving = 4;
|
||||
}
|
||||
|
||||
message QueueTrack {
|
||||
|
|
@ -187,6 +303,9 @@ message Track {
|
|||
string title = 3;
|
||||
optional uint32 duration = 4;
|
||||
optional Album album = 5;
|
||||
// The track has no playable audio (a capture recorded its source as
|
||||
// uncapturable). Clients mark it; playback skips it.
|
||||
bool is_skipped = 6;
|
||||
}
|
||||
|
||||
message LibraryNode {
|
||||
|
|
@ -197,4 +316,12 @@ message LibraryNode {
|
|||
optional string parent = 4;
|
||||
repeated Track tracks = 5;
|
||||
bool is_queable = 6;
|
||||
// Children may be created under this node (see CreateLibraryNode).
|
||||
bool is_creatable = 7;
|
||||
// This node allows download captures; its listed tracks inherit the
|
||||
// flag (CaptureLibraryNode with download).
|
||||
bool is_downloadable = 8;
|
||||
// This node's listed tracks may be deleted (see DeleteLibraryNode) —
|
||||
// like is_downloadable, tracks inherit the node's flag.
|
||||
bool tracks_deletable = 9;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::{
|
||||
fs::{create_dir_all, read_to_string, File},
|
||||
io::Write,
|
||||
|
|
@ -5,6 +6,7 @@ use std::{
|
|||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use clap_serde_derive::{self, clap, serde, ClapSerde};
|
||||
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
||||
|
||||
|
|
@ -28,15 +30,110 @@ pub trait ProviderClient: std::fmt::Debug + Send + Sync {
|
|||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
||||
fn get_lib_root(&self) -> LibraryNode;
|
||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
||||
/// Creates a child node under a creatable parent (`LibraryNode.is_creatable`).
|
||||
///
|
||||
/// What creation means is provider-defined; under `/tidal/search` the
|
||||
/// `title` is a search term and the created node holds its results.
|
||||
/// Idempotent: an existing title returns the existing node. Errors:
|
||||
/// [`ProviderError::NotSupported`] when the parent is not creatable,
|
||||
/// [`ProviderError::InvalidInput`] when the title is empty or
|
||||
/// whitespace-only.
|
||||
async fn create_lib_node(
|
||||
&self,
|
||||
parent_path: &str,
|
||||
title: &str,
|
||||
) -> Result<LibraryNode, ProviderError>;
|
||||
/// Renames a node advertised as editable (`LibraryNodeChild.is_editable`).
|
||||
///
|
||||
/// For a search term the title is the query, so a rename re-runs the
|
||||
/// search; the node's path changes with the title. Renaming onto an
|
||||
/// existing sibling title merges with it (that node is returned).
|
||||
/// Errors: [`ProviderError::NotSupported`] when the path is not
|
||||
/// editable, [`ProviderError::InvalidInput`] when the new title is empty
|
||||
/// or whitespace-only.
|
||||
async fn rename_lib_node(
|
||||
&self,
|
||||
path: &str,
|
||||
new_title: &str,
|
||||
) -> Result<LibraryNode, ProviderError>;
|
||||
/// Deletes a node advertised as deletable (`LibraryNodeChild.is_deletable`).
|
||||
///
|
||||
/// Idempotent: deleting an already-gone node succeeds. Returns the
|
||||
/// refreshed parent node (what a client should display next). Errors:
|
||||
/// [`ProviderError::NotSupported`] when the path is not deletable.
|
||||
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
||||
/// Streams the playable tracks under `path` into `chunk_tx`, in playback
|
||||
/// order.
|
||||
///
|
||||
/// This is a local bounded channel used as a stream, and its delivery
|
||||
/// semantics are the contract:
|
||||
///
|
||||
/// - Zero or more non-empty chunks are sent, in playback order.
|
||||
/// - Resolution is finished when the **sender** is dropped (this method
|
||||
/// returning). There is no end-of-stream marker.
|
||||
/// - Dropping the **receiver** cancels resolution: the provider stops
|
||||
/// fetching at the next send and returns `Ok`.
|
||||
/// - An unreadable node inside the walk is skipped with a warning; only
|
||||
/// a `path` that cannot be resolved at all is an `Err`.
|
||||
///
|
||||
/// A track path yields exactly one single-track chunk. The default
|
||||
/// implementation walks the node's queueable descendants depth-first in
|
||||
/// pre-order and emits one chunk per node holding tracks; providers
|
||||
/// should override it when they can produce finer-grained chunks (e.g.
|
||||
/// one per fetched page of a large collection).
|
||||
async fn resolve_tracks_into(
|
||||
&self,
|
||||
path: &str,
|
||||
chunk_tx: flume::Sender<Vec<Track>>,
|
||||
) -> Result<(), ProviderError> {
|
||||
if self.is_track_path(path) {
|
||||
match self.get_metadata_for_track(path).await {
|
||||
Ok(track) => {
|
||||
let _ = chunk_tx.send_async(vec![track]).await;
|
||||
}
|
||||
Err(err) => tracing::warn!(path, "failed to resolve track: {err}"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Depth-first pre-order so tracks arrive in listing order; children
|
||||
// are pushed reversed because the worklist pops from the back.
|
||||
let mut nodes_to_go = vec![path.to_string()];
|
||||
let mut at_root = true;
|
||||
while let Some(node_path) = nodes_to_go.pop() {
|
||||
let node = match self.get_lib_node(&node_path).await {
|
||||
Ok(node) => node,
|
||||
Err(err) if at_root => return Err(err),
|
||||
Err(err) => {
|
||||
tracing::warn!(node = node_path, "skipping unreadable node: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
at_root = false;
|
||||
if !node.is_queable {
|
||||
continue;
|
||||
}
|
||||
if !node.tracks.is_empty() && chunk_tx.send_async(node.tracks).await.is_err() {
|
||||
// Receiver gone: the consumer cancelled, stop fetching.
|
||||
return Ok(());
|
||||
}
|
||||
nodes_to_go.extend(node.children.into_iter().rev().map(|c| c.path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum ProviderError {
|
||||
Config(String),
|
||||
UnknownUser,
|
||||
CouldNotLogin,
|
||||
FetchError,
|
||||
MalformedPath,
|
||||
/// The operation is not supported at this path (e.g. creating a node
|
||||
/// under a parent that is not creatable).
|
||||
NotSupported,
|
||||
/// User-supplied input was rejected (e.g. an empty node title).
|
||||
InvalidInput,
|
||||
InternalError,
|
||||
Other,
|
||||
}
|
||||
|
|
@ -78,6 +175,32 @@ pub fn path_segments(path: &str) -> Vec<&str> {
|
|||
path.split('/').filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Percent-encodes arbitrary user text into a single path segment.
|
||||
///
|
||||
/// `/`, `%`, whitespace and every other character that would confuse
|
||||
/// `path_segments` or a URL are escaped: `AC/DC` -> `AC%2FDC`. The result is
|
||||
/// never empty for non-empty input and round-trips through
|
||||
/// [`decode_segment`].
|
||||
pub fn encode_segment(text: &str) -> String {
|
||||
/// Everything except ASCII alphanumerics and `-`, `_`, `.`, `~` is
|
||||
/// escaped — the URL "unreserved" set. Notably `/`, `%` and whitespace.
|
||||
const SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'~');
|
||||
percent_encoding::utf8_percent_encode(text, SEGMENT).to_string()
|
||||
}
|
||||
|
||||
/// Decodes a segment produced by [`encode_segment`] back to the original
|
||||
/// text. Invalid or lone percent escapes decode lossily (the raw bytes are
|
||||
/// kept) rather than erroring: paths come from clients and must not panic.
|
||||
pub fn decode_segment(segment: &str) -> String {
|
||||
percent_encoding::percent_decode_str(segment)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
impl LibraryNode {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -87,16 +210,27 @@ impl LibraryNode {
|
|||
parent: None,
|
||||
tracks: Vec::new(),
|
||||
is_queable: false,
|
||||
is_creatable: false,
|
||||
is_downloadable: false,
|
||||
tracks_deletable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LibraryNodeChild {
|
||||
/// A regular, non-creatable, immutable child. Creatable/editable/
|
||||
/// deletable/downloadable children (e.g. the search node and its
|
||||
/// terms, or Tidal's queueable subtrees) set the capability flags
|
||||
/// explicitly via struct update.
|
||||
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
||||
Self {
|
||||
path,
|
||||
title,
|
||||
is_queable,
|
||||
is_creatable: false,
|
||||
is_editable: false,
|
||||
is_deletable: false,
|
||||
is_downloadable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,6 +239,7 @@ pub enum QueueError {
|
|||
NotQueable,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init_config<T>(config_file_name: &str) -> T
|
||||
where
|
||||
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,
|
||||
|
|
@ -163,4 +298,298 @@ mod tests {
|
|||
vec!["tidal", "artists", "1", "2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_segment_round_trips_arbitrary_text() {
|
||||
for term in [
|
||||
"AC/DC",
|
||||
"100% wrong",
|
||||
"Björk",
|
||||
"hello world",
|
||||
"a%2Fb",
|
||||
"?!#&=",
|
||||
] {
|
||||
let encoded = encode_segment(term);
|
||||
assert_eq!(decode_segment(&encoded), term, "round trip of {term:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_segments_are_path_safe() {
|
||||
for term in ["AC/DC", "a/b/c", "//", "term with spaces"] {
|
||||
let encoded = encode_segment(term);
|
||||
assert!(!encoded.is_empty());
|
||||
assert!(!encoded.contains('/'), "{encoded:?} must be one segment");
|
||||
// Joining under a parent yields exactly one extra segment.
|
||||
let path = join_path("/tidal/search", &encoded);
|
||||
assert_eq!(path_segments(&path).len(), 3, "path {path:?}");
|
||||
assert_eq!(parent_path(&path), Some("/tidal/search"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_segment_is_lossy_not_panicky() {
|
||||
// Invalid or truncated escapes must never panic — paths come from
|
||||
// clients. Exact output is unspecified, only totality matters.
|
||||
for bad in ["%", "%2", "%zz", "abc%", "%%25"] {
|
||||
let _ = decode_segment(bad);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted in-memory provider for exercising the default
|
||||
/// `resolve_tracks_into` walk. Node lookups are recorded so tests can
|
||||
/// assert what was (not) fetched.
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeProvider {
|
||||
nodes: std::collections::HashMap<String, Result<LibraryNode, ProviderError>>,
|
||||
track_paths: Vec<String>,
|
||||
fetched: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeProvider {
|
||||
fn node(path: &str, tracks: &[&str], children: &[&str], is_queable: bool) -> LibraryNode {
|
||||
LibraryNode {
|
||||
path: path.to_string(),
|
||||
title: path.to_string(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|c| LibraryNodeChild::new(c.to_string(), c.to_string(), true))
|
||||
.collect(),
|
||||
parent: None,
|
||||
tracks: tracks
|
||||
.iter()
|
||||
.map(|t| Track {
|
||||
path: t.to_string(),
|
||||
artist: "artist".to_string(),
|
||||
title: t.to_string(),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
})
|
||||
.collect(),
|
||||
is_queable,
|
||||
is_creatable: false,
|
||||
is_downloadable: false,
|
||||
tracks_deletable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetched(&self) -> Vec<String> {
|
||||
self.fetched.lock().map(|f| f.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderClient for FakeProvider {
|
||||
async fn init(_: &str) -> Result<Self, ProviderError> {
|
||||
Ok(Self::default())
|
||||
}
|
||||
fn settings(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
fn is_track_path(&self, path: &str) -> bool {
|
||||
self.track_paths.iter().any(|p| p == path)
|
||||
}
|
||||
async fn get_urls_for_track(&self, _: &str) -> Result<Vec<String>, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn get_metadata_for_track(&self, path: &str) -> Result<Track, ProviderError> {
|
||||
Ok(Track {
|
||||
path: path.to_string(),
|
||||
artist: "artist".to_string(),
|
||||
title: path.to_string(),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
})
|
||||
}
|
||||
fn get_lib_root(&self) -> LibraryNode {
|
||||
LibraryNode::new()
|
||||
}
|
||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||
if let Ok(mut fetched) = self.fetched.lock() {
|
||||
fetched.push(path.to_string());
|
||||
}
|
||||
self.nodes
|
||||
.get(path)
|
||||
.cloned()
|
||||
.unwrap_or(Err(ProviderError::MalformedPath))
|
||||
}
|
||||
async fn create_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn rename_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn delete_lib_node(&self, _: &str) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the default resolve against the fake and collects the chunks it
|
||||
/// streamed. The channel is bounded but larger than any test tree, so
|
||||
/// the resolve never blocks on a full buffer here.
|
||||
async fn resolve_chunks(provider: &FakeProvider, path: &str) -> Vec<Vec<String>> {
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(32);
|
||||
provider
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await
|
||||
.expect("resolve failed");
|
||||
chunk_rx
|
||||
.into_iter()
|
||||
.map(|chunk| chunk.into_iter().map(|t| t.path).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_resolve_streams_chunks_per_node_in_preorder() {
|
||||
let mut provider = FakeProvider::default();
|
||||
// artist -> [album1, album2], each album carries tracks; the artist
|
||||
// node itself has none. Pre-order and *listing order*: album1's
|
||||
// tracks must come before album2's (the old walk popped LIFO and
|
||||
// reversed siblings).
|
||||
provider.nodes.insert(
|
||||
"/p/artist".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/artist",
|
||||
&[],
|
||||
&["/p/artist/al1", "/p/artist/al2"],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
provider.nodes.insert(
|
||||
"/p/artist/al1".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/artist/al1",
|
||||
&["/p/artist/al1/t1", "/p/artist/al1/t2"],
|
||||
&[],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
provider.nodes.insert(
|
||||
"/p/artist/al2".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/artist/al2",
|
||||
&["/p/artist/al2/t3"],
|
||||
&[],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
let chunks = resolve_chunks(&provider, "/p/artist").await;
|
||||
// One chunk per track-bearing node; the trackless artist node adds
|
||||
// no empty chunk.
|
||||
assert_eq!(
|
||||
chunks,
|
||||
vec![
|
||||
vec!["/p/artist/al1/t1".to_string(), "/p/artist/al1/t2".into()],
|
||||
vec!["/p/artist/al2/t3".to_string()],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_resolve_yields_one_chunk_for_track_paths() {
|
||||
let mut provider = FakeProvider::default();
|
||||
provider.track_paths.push("/p/al/t9".into());
|
||||
let chunks = resolve_chunks(&provider, "/p/al/t9").await;
|
||||
assert_eq!(chunks, vec![vec!["/p/al/t9".to_string()]]);
|
||||
assert!(
|
||||
provider.fetched().is_empty(),
|
||||
"a track path must not fetch nodes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_resolve_skips_unreadable_nodes_and_unqueable_subtrees() {
|
||||
let mut provider = FakeProvider::default();
|
||||
provider.nodes.insert(
|
||||
"/p/root".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/root",
|
||||
&["/p/root/t0"],
|
||||
&["/p/root/broken", "/p/root/private", "/p/root/ok"],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
provider
|
||||
.nodes
|
||||
.insert("/p/root/broken".into(), Err(ProviderError::FetchError));
|
||||
provider.nodes.insert(
|
||||
"/p/root/private".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/root/private",
|
||||
&["/p/root/private/hidden"],
|
||||
&[],
|
||||
false,
|
||||
)),
|
||||
);
|
||||
provider.nodes.insert(
|
||||
"/p/root/ok".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/root/ok",
|
||||
&["/p/root/ok/t1"],
|
||||
&[],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
let chunks = resolve_chunks(&provider, "/p/root").await;
|
||||
// The broken sibling is skipped, the non-queueable subtree
|
||||
// contributes nothing, the rest still resolves in order.
|
||||
assert_eq!(
|
||||
chunks,
|
||||
vec![
|
||||
vec!["/p/root/t0".to_string()],
|
||||
vec!["/p/root/ok/t1".to_string()],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_resolve_stops_fetching_once_the_receiver_is_gone() {
|
||||
let mut provider = FakeProvider::default();
|
||||
provider.nodes.insert(
|
||||
"/p/a".into(),
|
||||
Ok(FakeProvider::node(
|
||||
"/p/a",
|
||||
&["/p/a/t1"],
|
||||
&["/p/a/b", "/p/a/c"],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
provider.nodes.insert(
|
||||
"/p/a/b".into(),
|
||||
Ok(FakeProvider::node("/p/a/b", &["/p/a/b/t2"], &[], true)),
|
||||
);
|
||||
provider.nodes.insert(
|
||||
"/p/a/c".into(),
|
||||
Ok(FakeProvider::node("/p/a/c", &["/p/a/c/t3"], &[], true)),
|
||||
);
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(32);
|
||||
drop(chunk_rx);
|
||||
// A dropped receiver is cancellation, not an error ...
|
||||
provider
|
||||
.resolve_tracks_into("/p/a", chunk_tx)
|
||||
.await
|
||||
.expect("cancellation must not be an error");
|
||||
// ... and the walk stops fetching instead of draining the tree.
|
||||
assert_eq!(provider.fetched(), vec!["/p/a".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_resolve_errors_only_for_an_unresolvable_root() {
|
||||
let provider = FakeProvider::default();
|
||||
let (chunk_tx, _chunk_rx) = flume::bounded::<Vec<Track>>(1);
|
||||
let result = provider.resolve_tracks_into("/p/unknown", chunk_tx).await;
|
||||
assert!(result.is_err(), "an unreadable root path is an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_new_defaults_all_capability_flags_off() {
|
||||
// Wire contract: plain children are immutable; providers opt into
|
||||
// capabilities explicitly via struct update.
|
||||
let child = LibraryNodeChild::new("/tidal/x".to_string(), "x".to_string(), true);
|
||||
assert!(!child.is_creatable);
|
||||
assert!(!child.is_editable);
|
||||
assert!(!child.is_deletable);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,19 +7,47 @@ edition.workspace = true
|
|||
name = "crabidy-server"
|
||||
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]
|
||||
anyhow.workspace = true
|
||||
argon2.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
|
||||
crabidy-core.workspace = true
|
||||
dirs.workspace = true
|
||||
flume.workspace = true
|
||||
fsdy.workspace = true
|
||||
futures.workspace = true
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tidaldy.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
toml.workspace = true
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
tonic.workspace = true
|
||||
tonic = { workspace = true, features = ["router", "transport", "codegen"] }
|
||||
tracing.workspace = true
|
||||
tracing-appender.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
ytdy.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
argon2.workspace = true
|
||||
base64.workspace = true
|
||||
http.workspace = true
|
||||
tempfile.workspace = true
|
||||
tower.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Stages the web client bundle for embedding (feature `web-ui`,
|
||||
//! architecture/web-client.md): copies `cbd-web/dist` (the trunk
|
||||
//! output) into `OUT_DIR/webdist`, or generates a placeholder page
|
||||
//! when the bundle has not been built — a plain `cargo build` must
|
||||
//! neither fail nor require the wasm toolchain. Deliberately no
|
||||
//! cargo-in-cargo: this never invokes trunk itself.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
// Rerun when the bundle changes (or appears).
|
||||
println!("cargo:rerun-if-changed=../cbd-web/dist");
|
||||
if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() {
|
||||
return;
|
||||
}
|
||||
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
|
||||
let staged = Path::new(&out_dir).join("webdist");
|
||||
// Start fresh so removed assets do not linger across builds.
|
||||
if staged.exists() {
|
||||
std::fs::remove_dir_all(&staged).expect("clean staged webdist");
|
||||
}
|
||||
std::fs::create_dir_all(&staged).expect("create staged webdist");
|
||||
|
||||
let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../cbd-web/dist");
|
||||
if dist.join("index.html").is_file() {
|
||||
copy_dir(&dist, &staged);
|
||||
} else {
|
||||
println!(
|
||||
"cargo:warning=cbd-web/dist not found - embedding a placeholder web UI \
|
||||
(build the bundle with: devenv shell -- build-web)"
|
||||
);
|
||||
std::fs::write(
|
||||
staged.join("index.html"),
|
||||
"<!doctype html><meta charset=\"utf-8\"><title>crabidy</title>\
|
||||
<body style=\"font:16px system-ui;padding:2rem\">\
|
||||
<h1>crabidy web UI not built</h1>\
|
||||
<p>This server binary was compiled without the web bundle. \
|
||||
Build it with <code>devenv shell -- build-web</code> and \
|
||||
rebuild the server.</p>",
|
||||
)
|
||||
.expect("write placeholder index.html");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `from` into `to` recursively (regular files only — the trunk
|
||||
/// output contains nothing else).
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
for entry in std::fs::read_dir(from).expect("read dist dir") {
|
||||
let entry = entry.expect("dist dir entry");
|
||||
let target = to.join(entry.file_name());
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
std::fs::create_dir_all(&target).expect("create staged subdir");
|
||||
copy_dir(&path, &target);
|
||||
} else {
|
||||
std::fs::copy(&path, &target).expect("copy dist file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
//! Role-based authorization for the gRPC surface
|
||||
//! (architecture/roles-auth.md).
|
||||
//!
|
||||
//! Enforcement lives in exactly one place: [`AuthLayer`], a tower layer
|
||||
//! in front of the tonic service. It authenticates the HTTP basic-auth
|
||||
//! header against the configured role hashes and checks the resulting
|
||||
//! [`Role`] against the method's [`minimum_role`] — *before* any
|
||||
//! handler runs, default-deny for methods it does not know. Handlers
|
||||
//! never see unauthorized requests and did not change for this feature.
|
||||
//!
|
||||
//! Credentials never appear in logs or error messages.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use base64::Engine;
|
||||
use futures::future::{ready, Either, Ready};
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::settings::AuthSettings;
|
||||
|
||||
/// The three roles, ordered by privilege: every role includes the
|
||||
/// rights of the roles below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Role {
|
||||
/// May read and append tracks to the queue (plus create search
|
||||
/// terms — the mechanism of finding something to append).
|
||||
QueueAppender,
|
||||
/// Anything on the queue and playback, but no library writes.
|
||||
QueueOwner,
|
||||
/// The normal user: everything.
|
||||
Owner,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// The basic-auth user name selecting this role.
|
||||
fn from_user(user: &str) -> Option<Role> {
|
||||
match user {
|
||||
"owner" => Some(Role::Owner),
|
||||
"queue-owner" => Some(Role::QueueOwner),
|
||||
"queue-appender" => Some(Role::QueueAppender),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Role::Owner => "owner",
|
||||
Role::QueueOwner => "queue-owner",
|
||||
Role::QueueAppender => "queue-appender",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gRPC path prefix of our service's methods.
|
||||
const SERVICE_PREFIX: &str = "/crabidy.v1.CrabidyService/";
|
||||
|
||||
/// Minimum role required for a gRPC request path (the rights matrix,
|
||||
/// architecture/roles-auth.md). Unknown methods — including anything
|
||||
/// outside our service — require [`Role::Owner`]: fail-closed, a
|
||||
/// future RPC starts locked until it is mapped here (a test pins the
|
||||
/// full method list, so forgetting fails the suite).
|
||||
pub fn minimum_role(grpc_path: &str) -> Role {
|
||||
let Some(method) = grpc_path.strip_prefix(SERVICE_PREFIX) else {
|
||||
return Role::Owner;
|
||||
};
|
||||
match method {
|
||||
// Reads, the one appender queue verb, and search-term creation.
|
||||
"Init" | "GetLibraryNode" | "GetUpdateStream" | "Append" | "CreateLibraryNode" => {
|
||||
Role::QueueAppender
|
||||
}
|
||||
// Every other queue and playback verb.
|
||||
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
|
||||
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
|
||||
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" => Role::QueueOwner,
|
||||
// Library writes (CaptureLibraryNode, SaveQueue,
|
||||
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
|
||||
_ => Role::Owner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes a password into the PHC string `crabidy-server.toml` expects
|
||||
/// (argon2id, default parameters, fresh random salt). Backs the
|
||||
/// `crabidy-server hash-password` helper.
|
||||
pub fn hash_password(password: &str) -> Result<String, String> {
|
||||
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
||||
use argon2::PasswordHasher;
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
// The error is parameter trouble, never the password itself.
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|err| format!("cannot hash password: {err}"))
|
||||
}
|
||||
|
||||
/// Verifies basic-auth credentials against the configured role hashes.
|
||||
///
|
||||
/// Argon2 verification is deliberately slow, so *successful* header
|
||||
/// values are cached (value → role); the cache is fed only by
|
||||
/// successes, bounding it by the number of valid credentials. Failures
|
||||
/// re-verify every time, which doubles as throttling.
|
||||
pub struct Authenticator {
|
||||
/// `(role, PHC hash)` pairs from the config; empty = auth off.
|
||||
hashes: Vec<(Role, String)>,
|
||||
verified: RwLock<HashMap<String, Role>>,
|
||||
}
|
||||
|
||||
impl Authenticator {
|
||||
pub fn new(settings: &AuthSettings) -> Self {
|
||||
let mut hashes = Vec::new();
|
||||
for (role, hash) in [
|
||||
(Role::Owner, &settings.owner),
|
||||
(Role::QueueOwner, &settings.queue_owner),
|
||||
(Role::QueueAppender, &settings.queue_appender),
|
||||
] {
|
||||
if let Some(hash) = hash {
|
||||
// Reject unusable hashes at startup, when the operator
|
||||
// is looking — not at the first login attempt.
|
||||
if let Err(err) = PasswordHash::new(hash) {
|
||||
warn!(
|
||||
role = role.name(),
|
||||
"unusable password hash in config: {err}"
|
||||
);
|
||||
} else {
|
||||
hashes.push((role, hash.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
hashes,
|
||||
verified: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any credential is configured (the auth on/off switch).
|
||||
pub fn enabled(&self) -> bool {
|
||||
!self.hashes.is_empty()
|
||||
}
|
||||
|
||||
/// Resolves the request's `authorization` header value to a role.
|
||||
///
|
||||
/// With auth disabled everyone is [`Role::Owner`]. Every failure —
|
||||
/// missing header, wrong scheme, broken base64, unknown user,
|
||||
/// wrong password — answers the same `UNAUTHENTICATED` so callers
|
||||
/// cannot probe which part was wrong. Never panics on input.
|
||||
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
|
||||
if !self.enabled() {
|
||||
return Ok(Role::Owner);
|
||||
}
|
||||
let denied = || Status::unauthenticated("credentials required");
|
||||
let header = header.ok_or_else(denied)?;
|
||||
if let Some(role) = self
|
||||
.verified
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|cache| cache.get(header).copied())
|
||||
{
|
||||
return Ok(role);
|
||||
}
|
||||
let encoded = header
|
||||
.strip_prefix("Basic ")
|
||||
.or_else(|| header.strip_prefix("basic "))
|
||||
.ok_or_else(denied)?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded.trim())
|
||||
.map_err(|_| denied())?;
|
||||
let decoded = String::from_utf8(decoded).map_err(|_| denied())?;
|
||||
let (user, password) = decoded.split_once(':').ok_or_else(denied)?;
|
||||
let role = Role::from_user(user).ok_or_else(denied)?;
|
||||
let hash = self
|
||||
.hashes
|
||||
.iter()
|
||||
.find(|(r, _)| *r == role)
|
||||
.map(|(_, h)| h)
|
||||
.ok_or_else(denied)?;
|
||||
// Validated in `new`; a parse failure here is unreachable but
|
||||
// must still deny, not panic.
|
||||
let parsed = PasswordHash::new(hash).map_err(|_| denied())?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.map_err(|_| denied())?;
|
||||
if let Ok(mut cache) = self.verified.write() {
|
||||
cache.insert(header.to_string(), role);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn cached(&self) -> usize {
|
||||
self.verified.read().map(|c| c.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower layer installing [`AuthService`] in front of the tonic
|
||||
/// service.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthLayer {
|
||||
auth: Arc<Authenticator>,
|
||||
}
|
||||
|
||||
impl AuthLayer {
|
||||
pub fn new(auth: Arc<Authenticator>) -> Self {
|
||||
Self { auth }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> tower::Layer<S> for AuthLayer {
|
||||
type Service = AuthService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
AuthService {
|
||||
inner,
|
||||
auth: self.auth.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single authorization gate: authenticates the header, compares
|
||||
/// the role against [`minimum_role`] of the request path, and either
|
||||
/// forwards to the inner service or answers a trailers-only gRPC error
|
||||
/// (`UNAUTHENTICATED` / `PERMISSION_DENIED`) without running any
|
||||
/// handler.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService<S> {
|
||||
inner: S,
|
||||
auth: Arc<Authenticator>,
|
||||
}
|
||||
|
||||
impl<S, ReqBody, ResBody> tower::Service<http::Request<ReqBody>> for AuthService<S>
|
||||
where
|
||||
S: tower::Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
|
||||
ResBody: Default,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = Either<S::Future, Ready<Result<Self::Response, Self::Error>>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: http::Request<ReqBody>) -> Self::Future {
|
||||
// The header value itself is a secret and is never logged.
|
||||
let header = req
|
||||
.headers()
|
||||
.get(http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let decision = self.auth.authenticate(header).and_then(|role| {
|
||||
let needed = minimum_role(req.uri().path());
|
||||
if role >= needed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Status::permission_denied(format!(
|
||||
"requires the {} role",
|
||||
needed.name()
|
||||
)))
|
||||
}
|
||||
});
|
||||
match decision {
|
||||
Ok(()) => Either::Left(self.inner.call(req)),
|
||||
Err(status) => Either::Right(ready(Ok(status.into_http()))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
||||
use argon2::PasswordHasher;
|
||||
|
||||
/// A PHC hash of `password` with cheap test parameters (the params
|
||||
/// travel inside the PHC string, so default verification reads
|
||||
/// them back).
|
||||
fn hash(password: &str) -> String {
|
||||
let params = argon2::Params::new(8, 1, 1, None).expect("params");
|
||||
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
.expect("hash")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn basic(user: &str, password: &str) -> String {
|
||||
let encoded =
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
|
||||
format!("Basic {encoded}")
|
||||
}
|
||||
|
||||
fn authenticator() -> Authenticator {
|
||||
Authenticator::new(&AuthSettings {
|
||||
owner: Some(hash("os")),
|
||||
queue_owner: Some(hash("qos")),
|
||||
queue_appender: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- the rights matrix -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_method_table_pins_every_rpc_of_the_service() {
|
||||
let appender = [
|
||||
"Init",
|
||||
"GetLibraryNode",
|
||||
"GetUpdateStream",
|
||||
"Append",
|
||||
"CreateLibraryNode",
|
||||
];
|
||||
let queue_owner = [
|
||||
"Queue",
|
||||
"Replace",
|
||||
"Remove",
|
||||
"Insert",
|
||||
"ClearQueue",
|
||||
"SetCurrent",
|
||||
"ToggleShuffle",
|
||||
"ToggleRepeat",
|
||||
"TogglePlay",
|
||||
"Stop",
|
||||
"ChangeVolume",
|
||||
"ToggleMute",
|
||||
"Next",
|
||||
"Prev",
|
||||
"RestartTrack",
|
||||
];
|
||||
let owner = [
|
||||
"CaptureLibraryNode",
|
||||
"SaveQueue",
|
||||
"RenameLibraryNode",
|
||||
"DeleteLibraryNode",
|
||||
];
|
||||
// The full service, from crabidy.proto — 24 methods. A new RPC
|
||||
// must be added to exactly one list (and the layer keeps it
|
||||
// owner-only until then).
|
||||
assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24);
|
||||
for method in appender {
|
||||
let path = format!("{SERVICE_PREFIX}{method}");
|
||||
assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}");
|
||||
}
|
||||
for method in queue_owner {
|
||||
let path = format!("{SERVICE_PREFIX}{method}");
|
||||
assert_eq!(minimum_role(&path), Role::QueueOwner, "{method}");
|
||||
}
|
||||
for method in owner {
|
||||
let path = format!("{SERVICE_PREFIX}{method}");
|
||||
assert_eq!(minimum_role(&path), Role::Owner, "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_methods_and_foreign_services_are_owner_only() {
|
||||
assert_eq!(
|
||||
minimum_role("/crabidy.v1.CrabidyService/BrandNewRpc"),
|
||||
Role::Owner
|
||||
);
|
||||
assert_eq!(minimum_role("/grpc.health.v1.Health/Check"), Role::Owner);
|
||||
assert_eq!(minimum_role("nonsense"), Role::Owner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_are_ordered_by_privilege() {
|
||||
assert!(Role::Owner > Role::QueueOwner);
|
||||
assert!(Role::QueueOwner > Role::QueueAppender);
|
||||
}
|
||||
|
||||
// ---- authentication ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn without_configured_hashes_everyone_is_owner() {
|
||||
let auth = Authenticator::new(&AuthSettings::default());
|
||||
assert!(!auth.enabled());
|
||||
assert_eq!(auth.authenticate(None).expect("open"), Role::Owner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_credentials_resolve_their_role() {
|
||||
let auth = authenticator();
|
||||
assert_eq!(
|
||||
auth.authenticate(Some(&basic("owner", "os")))
|
||||
.expect("owner"),
|
||||
Role::Owner
|
||||
);
|
||||
assert_eq!(
|
||||
auth.authenticate(Some(&basic("queue-owner", "qos")))
|
||||
.expect("queue owner"),
|
||||
Role::QueueOwner
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_failure_is_the_same_unauthenticated() {
|
||||
let auth = authenticator();
|
||||
let cases: Vec<Option<String>> = vec![
|
||||
None, // no header
|
||||
Some("Bearer token".to_string()), // wrong scheme
|
||||
Some("Basic !!!not-base64!!!".to_string()), // broken base64
|
||||
Some("Basic bm9jb2xvbg==".to_string()), // no colon
|
||||
Some(basic("owner", "wrong")), // wrong password
|
||||
Some(basic("dj", "os")), // unknown user
|
||||
Some(basic("queue-appender", "anything")), // role without hash
|
||||
];
|
||||
let mut messages = Vec::new();
|
||||
for case in &cases {
|
||||
let err = auth
|
||||
.authenticate(case.as_deref())
|
||||
.expect_err(&format!("{case:?}"));
|
||||
assert_eq!(err.code(), tonic::Code::Unauthenticated, "{case:?}");
|
||||
messages.push(err.message().to_string());
|
||||
}
|
||||
assert!(
|
||||
messages.windows(2).all(|w| w[0] == w[1]),
|
||||
"failures must be indistinguishable"
|
||||
);
|
||||
assert_eq!(auth.cached(), 0, "failures are never cached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_password_output_round_trips_through_the_authenticator() {
|
||||
let phc = hash_password("hunter2").expect("hash");
|
||||
assert!(phc.starts_with("$argon2id$"), "PHC format");
|
||||
let auth = Authenticator::new(&AuthSettings {
|
||||
owner: Some(phc),
|
||||
queue_owner: None,
|
||||
queue_appender: None,
|
||||
});
|
||||
assert_eq!(
|
||||
auth.authenticate(Some(&basic("owner", "hunter2")))
|
||||
.expect("round trip"),
|
||||
Role::Owner
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_credentials_are_cached() {
|
||||
let auth = authenticator();
|
||||
let header = basic("owner", "os");
|
||||
assert_eq!(auth.cached(), 0);
|
||||
auth.authenticate(Some(&header)).expect("first");
|
||||
assert_eq!(auth.cached(), 1);
|
||||
auth.authenticate(Some(&header)).expect("cached");
|
||||
assert_eq!(auth.cached(), 1, "same credential, one entry");
|
||||
}
|
||||
|
||||
// ---- the layer -----------------------------------------------------
|
||||
|
||||
/// Calls the layered service once and returns the response plus
|
||||
/// whether the inner service ran.
|
||||
fn call_layer(
|
||||
auth: Arc<Authenticator>,
|
||||
path: &str,
|
||||
header: Option<&str>,
|
||||
) -> (http::Response<String>, bool) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tower::{Layer, Service, ServiceExt};
|
||||
let reached = Arc::new(AtomicBool::new(false));
|
||||
let flag = reached.clone();
|
||||
let inner = tower::service_fn(move |_req: http::Request<()>| {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
ready(Ok::<_, std::convert::Infallible>(http::Response::new(
|
||||
"handled".to_string(),
|
||||
)))
|
||||
});
|
||||
let mut service = AuthLayer::new(auth).layer(inner);
|
||||
let mut req = http::Request::new(());
|
||||
*req.uri_mut() = path.parse().expect("uri");
|
||||
if let Some(header) = header {
|
||||
req.headers_mut().insert(
|
||||
http::header::AUTHORIZATION,
|
||||
header.parse().expect("header value"),
|
||||
);
|
||||
}
|
||||
let response = futures::executor::block_on(async {
|
||||
service.ready().await.expect("ready").call(req).await
|
||||
})
|
||||
.expect("call");
|
||||
(response, reached.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
fn grpc_status(response: &http::Response<String>) -> Option<&str> {
|
||||
response
|
||||
.headers()
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layer_forwards_authorized_requests_only() {
|
||||
let auth = Arc::new(authenticator());
|
||||
let append = "http://s/crabidy.v1.CrabidyService/Append";
|
||||
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
|
||||
|
||||
// No credentials: unauthenticated, handler never runs.
|
||||
let (response, reached) = call_layer(auth.clone(), append, None);
|
||||
assert!(!reached);
|
||||
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
|
||||
|
||||
// Sufficient role: forwarded.
|
||||
let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os")));
|
||||
assert!(reached);
|
||||
assert_eq!(response.body(), "handled");
|
||||
|
||||
// Valid credentials, insufficient role: denied, handler never
|
||||
// runs, and the code distinguishes authorization from
|
||||
// authentication.
|
||||
let (response, reached) =
|
||||
call_layer(auth.clone(), capture, Some(&basic("queue-owner", "qos")));
|
||||
assert!(!reached);
|
||||
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
|
||||
|
||||
// Auth disabled: everything forwards without a header.
|
||||
let open = Arc::new(Authenticator::new(&AuthSettings::default()));
|
||||
let (_, reached) = call_layer(open, capture, None);
|
||||
assert!(reached);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
//! Captured library subtrees ("bookmarks") on disk
|
||||
//! (see `architecture/bookmarks.md`).
|
||||
//!
|
||||
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
|
||||
//! mirrors the captured subtree: one order-prefixed folder per child node,
|
||||
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
|
||||
//! directory is mounted read-only into the library as `/bookmarks` by an
|
||||
//! `fsdy` instance (with editable top-level folders) — this module is the
|
||||
//! only writer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crabidy_core::ProviderClient;
|
||||
|
||||
/// The library mount point of the bookmarks directory.
|
||||
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
|
||||
|
||||
/// The walk aborts beyond this many directories — a runaway provider tree
|
||||
/// must not fill the disk.
|
||||
pub const MAX_CAPTURE_DIRS: usize = crate::capture::BOOKMARK_CAPS.max_dirs;
|
||||
|
||||
/// The walk aborts beyond this many track files.
|
||||
pub const MAX_CAPTURE_TRACKS: usize = crate::capture::BOOKMARK_CAPS.max_tracks;
|
||||
|
||||
/// The bookmarks directory: `bookmarks/` inside the crabidy config
|
||||
/// directory. `None` when the platform has no config directory.
|
||||
pub fn bookmarks_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
|
||||
}
|
||||
|
||||
pub use crate::capture::CaptureError;
|
||||
|
||||
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
|
||||
/// built as a hidden temp sibling and swapped into place, so a crash never
|
||||
/// leaves a half-written bookmark next to intact ones.
|
||||
#[derive(Debug)]
|
||||
pub struct BookmarkStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl BookmarkStore {
|
||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||
/// missing.
|
||||
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
/// The store directory (what the `/bookmarks` provider instance
|
||||
/// mounts).
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Validates a bookmark request without writing anything: the name
|
||||
/// must be a legal folder name. The accept-then-stream RPC replies
|
||||
/// after this and runs [`Self::capture`] detached
|
||||
/// (architecture/incremental-captures.md D4).
|
||||
pub fn validate(&self, name: &str) -> Result<(), CaptureError> {
|
||||
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Captures the subtree at `source_path` as the bookmark `name`,
|
||||
/// overwriting an existing bookmark of that name. Reports through
|
||||
/// `progress` (non-terminal events only; the caller sends the
|
||||
/// terminal one).
|
||||
///
|
||||
/// Walks `client` (the orchestrator, so any provider is reachable)
|
||||
/// iteratively in pre-order: every child node becomes an
|
||||
/// order-prefixed folder, every track an order-prefixed link file
|
||||
/// ([`fsdy::TrackFile::from_track`]; a skipped source track writes a
|
||||
/// skipped toml). A `source_path` that is itself a track captures as
|
||||
/// a folder with one file. Aborts with [`CaptureError::TooLarge`]
|
||||
/// beyond [`MAX_CAPTURE_DIRS`] / [`MAX_CAPTURE_TRACKS`]; an
|
||||
/// unreadable source is [`CaptureError::BadSource`]. Never panics on
|
||||
/// provider contents.
|
||||
pub async fn capture<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
progress: &crate::capture::Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
self.capture_with_caps(
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
MAX_CAPTURE_DIRS,
|
||||
MAX_CAPTURE_TRACKS,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||
async fn capture_with_caps<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
max_dirs: usize,
|
||||
max_tracks: usize,
|
||||
progress: &crate::capture::Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let caps = crate::capture::Caps {
|
||||
max_dirs,
|
||||
max_tracks,
|
||||
..crate::capture::BOOKMARK_CAPS
|
||||
};
|
||||
crate::capture::capture_into(
|
||||
&self.dir,
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
caps,
|
||||
&crate::capture::Sink::Link,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A silent progress reporter; bookmark tests assert on disk state.
|
||||
fn silent() -> crate::capture::Progress {
|
||||
crate::capture::Progress::silent("test", false)
|
||||
}
|
||||
|
||||
/// A real fsdy instance as the capture source: an artist with two
|
||||
/// albums holding url tracks, plus one link track pointing at Tidal.
|
||||
async fn source() -> (fsdy::Client, TempDir) {
|
||||
let dir = TempDir::new().expect("source tempdir");
|
||||
let al1 = dir.path().join("artist/Album One");
|
||||
let al2 = dir.path().join("artist/Album Two");
|
||||
fs::create_dir_all(&al1).expect("mkdir");
|
||||
fs::create_dir_all(&al2).expect("mkdir");
|
||||
let url =
|
||||
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
|
||||
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
|
||||
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
|
||||
fs::write(
|
||||
al2.join("01 linked.cbd-track.toml"),
|
||||
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||
)
|
||||
.expect("write");
|
||||
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
|
||||
(client, dir)
|
||||
}
|
||||
|
||||
async fn store() -> (BookmarkStore, TempDir) {
|
||||
let dir = TempDir::new().expect("store tempdir");
|
||||
let store = BookmarkStore::open(dir.path().join("bookmarks"))
|
||||
.await
|
||||
.expect("open creates the directory");
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
fn visible(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = fs::read_dir(dir)
|
||||
.expect("dir")
|
||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_mirrors_a_subtree_with_order_prefixes() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&client, "/fs/artist", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let root = store.dir().join("faves");
|
||||
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
|
||||
let album1 = visible(&root.join("0001 Album One"));
|
||||
assert_eq!(
|
||||
album1,
|
||||
vec![
|
||||
"0001 one.cbd-track.toml".to_string(),
|
||||
"0002 two.cbd-track.toml".into()
|
||||
]
|
||||
);
|
||||
// Entries are link files; the url track links back to its /fs path,
|
||||
// the link track re-links to its original target (no chains).
|
||||
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
|
||||
.expect("read entry");
|
||||
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
|
||||
assert_eq!(
|
||||
one.to_track("/bookmarks/irrelevant").path,
|
||||
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
|
||||
);
|
||||
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
|
||||
.expect("read entry");
|
||||
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
|
||||
assert_eq!(
|
||||
linked.to_track("/bookmarks/irrelevant").path,
|
||||
"/tidal/artists/1/2"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captured_trees_replay_through_a_bookmarks_instance() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&client, "/fs/artist", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||
.expect("bookmarks instance");
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||
bookmarks
|
||||
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
|
||||
.await
|
||||
.expect("resolve");
|
||||
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
|
||||
// Pre-order over the mirrored structure == source listing order.
|
||||
assert_eq!(
|
||||
titles,
|
||||
vec!["one".to_string(), "two".into(), "linked".into()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capturing_a_single_track_writes_one_file() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(
|
||||
&client,
|
||||
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
|
||||
"just one",
|
||||
&silent(),
|
||||
)
|
||||
.await
|
||||
.expect("capture track");
|
||||
let entries = visible(&store.dir().join("just one"));
|
||||
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_validates_names_and_overwrites() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
for bad in ["", " ", "a/b", ".hidden"] {
|
||||
assert!(
|
||||
matches!(
|
||||
store.capture(&client, "/fs/artist", bad, &silent()).await,
|
||||
Err(CaptureError::InvalidName(_))
|
||||
),
|
||||
"name {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
store
|
||||
.capture(&client, "/fs/artist", "faves", &silent())
|
||||
.await
|
||||
.expect("first capture");
|
||||
store
|
||||
.capture(&client, "/fs/artist/Album%20Two", "faves", &silent())
|
||||
.await
|
||||
.expect("overwrite");
|
||||
// The overwrite fully replaces the older, larger capture.
|
||||
assert_eq!(
|
||||
visible(&store.dir().join("faves")),
|
||||
vec!["0001 linked.cbd-track.toml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_rejects_an_unreadable_source() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
assert!(matches!(
|
||||
store.capture(&client, "/fs/nope", "x", &silent()).await,
|
||||
Err(CaptureError::BadSource(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_aborts_over_the_caps_and_cleans_up() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
|
||||
let err = store
|
||||
.capture_with_caps(
|
||||
&client,
|
||||
"/fs/artist",
|
||||
"big",
|
||||
2,
|
||||
MAX_CAPTURE_TRACKS,
|
||||
&silent(),
|
||||
)
|
||||
.await
|
||||
.expect_err("over the dir cap");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||
// ... same for the track cap.
|
||||
let err = store
|
||||
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1, &silent())
|
||||
.await
|
||||
.expect_err("over the track cap");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||
// Nothing half-written survives, not even hidden temp folders.
|
||||
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
|
||||
assert_eq!(leftovers, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,828 @@
|
|||
//! The shared subtree-capture walk
|
||||
//! (see `architecture/captures.md` D2 and
|
||||
//! `architecture/incremental-captures.md` D2).
|
||||
//!
|
||||
//! Both bookmark captures (`w`, link files) and download captures (`W`,
|
||||
//! audio files next to their tomls) mirror a library subtree into a folder.
|
||||
//! The walk runs in two phases: **enumerate** first (every directory and
|
||||
//! track, enforcing the size caps — this makes the total known before the
|
||||
//! first download), then **fetch** track by track, reporting [`Progress`]
|
||||
//! after each one.
|
||||
//!
|
||||
//! The per-track [`Sink`] decides the write mode: bookmarks build the whole
|
||||
//! capture in a hidden tmp sibling and swap it into place (all-or-nothing,
|
||||
//! overwrite = refresh), download captures write **incrementally** into the
|
||||
//! final folder — entries that are already satisfied are reused, tracks
|
||||
//! whose source cannot be captured are recorded as *skipped* tomls, and a
|
||||
//! real download failure aborts the run but keeps everything written so
|
||||
//! far, so re-running the same name resumes where it stopped.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
||||
use crabidy_core::ProviderClient;
|
||||
use tracing::warn;
|
||||
|
||||
/// Connect timeout for download requests.
|
||||
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Bytes per download request window. Some CDNs (googlevideo) reject
|
||||
/// plain and open-ended requests from unattested clients with 403 and
|
||||
/// only serve bounded ranges around this size — the same windowing the
|
||||
/// player uses (audio-player/src/windowed_http.rs). Servers that ignore
|
||||
/// the `Range` header answer 200 with the whole body, which is handled
|
||||
/// as a single window.
|
||||
pub const DOWNLOAD_WINDOW: u64 = 1024 * 1024;
|
||||
|
||||
/// Total per-track deadline: URL fetch, request, and streaming the whole
|
||||
/// body. A stalled transfer aborts the capture instead of hanging it.
|
||||
/// Generous: tokenless YouTube URLs are throttled to ~32 KB/s, so a long
|
||||
/// track legitimately takes many minutes.
|
||||
pub const DOWNLOAD_TRACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1800);
|
||||
|
||||
/// Size limits for one capture. The walk aborts with
|
||||
/// [`CaptureError::TooLarge`] when a limit trips — a runaway provider tree
|
||||
/// or oversized stream must not fill the disk.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Caps {
|
||||
/// Maximum directories (the capture root counts as the first).
|
||||
pub max_dirs: usize,
|
||||
/// Maximum track files.
|
||||
pub max_tracks: usize,
|
||||
/// Maximum total bytes downloaded in one run (ignored by
|
||||
/// [`Sink::Link`]). Reused entries of a resumed capture do not count.
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
/// Caps for bookmark (link) captures: link files are tiny, so only the
|
||||
/// tree size is bounded.
|
||||
pub const BOOKMARK_CAPS: Caps = Caps {
|
||||
max_dirs: 1_000,
|
||||
max_tracks: 20_000,
|
||||
max_bytes: u64::MAX,
|
||||
};
|
||||
|
||||
/// Caps for download captures: fewer tracks and a 4 GiB byte budget.
|
||||
pub const DOWNLOAD_CAPS: Caps = Caps {
|
||||
max_dirs: 1_000,
|
||||
max_tracks: 500,
|
||||
max_bytes: 4 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
/// Errors from validating or writing a capture.
|
||||
///
|
||||
/// At the RPC boundary: `InvalidName`/`BadSource` → `invalid_argument`,
|
||||
/// `TooLarge`/`Disabled`/`Unsupported` → `failed_precondition`, the rest →
|
||||
/// `internal`. Messages carry names, paths, and counts, never file
|
||||
/// contents or stream URLs.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CaptureError {
|
||||
#[error("invalid name: {0}")]
|
||||
InvalidName(&'static str),
|
||||
#[error("the store is disabled")]
|
||||
Disabled,
|
||||
#[error("the source path cannot be captured: {0}")]
|
||||
BadSource(String),
|
||||
#[error("the source does not allow downloads")]
|
||||
Unsupported,
|
||||
#[error("the subtree is too large to capture ({0})")]
|
||||
TooLarge(&'static str),
|
||||
#[error("download failed: {0}")]
|
||||
Download(String),
|
||||
#[error("cannot write capture: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
TrackFile(#[from] fsdy::TrackFileError),
|
||||
}
|
||||
|
||||
/// Progress reporting for one capture run
|
||||
/// (`architecture/incremental-captures.md` D4).
|
||||
///
|
||||
/// The walk bumps the counters as it settles tracks; every bump publishes a
|
||||
/// non-terminal [`CaptureProgress`] snapshot to the (bounded) channel,
|
||||
/// **lossily** — a full channel drops the snapshot, never blocks the walk.
|
||||
/// The terminal event is sent exactly once via [`Self::finish`] and is not
|
||||
/// lossy. A [`Self::silent`] reporter counts without a channel.
|
||||
#[derive(Debug)]
|
||||
pub struct Progress {
|
||||
name: String,
|
||||
download: bool,
|
||||
tx: Option<flume::Sender<CaptureProgress>>,
|
||||
done: AtomicU32,
|
||||
total: AtomicU32,
|
||||
skipped: AtomicU32,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// A reporter publishing to `tx`.
|
||||
pub fn new(name: &str, download: bool, tx: flume::Sender<CaptureProgress>) -> Self {
|
||||
Self {
|
||||
tx: Some(tx),
|
||||
..Self::silent(name, download)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reporter that only counts (tests, callers without a stream).
|
||||
pub fn silent(name: &str, download: bool) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
download,
|
||||
tx: None,
|
||||
done: AtomicU32::new(0),
|
||||
total: AtomicU32::new(0),
|
||||
skipped: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire snapshot of the current counters.
|
||||
fn snapshot(&self, finished: bool, error: String) -> CaptureProgress {
|
||||
CaptureProgress {
|
||||
name: self.name.clone(),
|
||||
download: self.download,
|
||||
tracks_done: self.done.load(Ordering::Relaxed),
|
||||
tracks_total: self.total.load(Ordering::Relaxed),
|
||||
tracks_skipped: self.skipped.load(Ordering::Relaxed),
|
||||
finished,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a non-terminal snapshot; lossy on a full channel.
|
||||
fn publish(&self) {
|
||||
if let Some(tx) = &self.tx {
|
||||
let _ = tx.try_send(self.snapshot(false, String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_total(&self, total: usize) {
|
||||
self.total
|
||||
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// One track settled with playable data (reused, downloaded, linked).
|
||||
fn track_done(&self) {
|
||||
self.done.fetch_add(1, Ordering::Relaxed);
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// One track settled as skipped (counts toward done — the ratio must
|
||||
/// reach total on success).
|
||||
fn track_skipped(&self) {
|
||||
self.skipped.fetch_add(1, Ordering::Relaxed);
|
||||
self.done.fetch_add(1, Ordering::Relaxed);
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// Sends the terminal event: the capture is over, `error` says why it
|
||||
/// failed (or `None` on success). A vanished receiver is ignored — the
|
||||
/// capture's outcome is on disk and in the log either way.
|
||||
pub async fn finish(&self, error: Option<String>) {
|
||||
if let Some(tx) = &self.tx {
|
||||
let _ = tx
|
||||
.send_async(self.snapshot(true, error.unwrap_or_default()))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to each track the walk visits.
|
||||
#[derive(Debug)]
|
||||
pub enum Sink {
|
||||
/// Write an order-prefixed link file ([`fsdy::TrackFile::from_track`])
|
||||
/// — the bookmark behavior: tmp-and-swap, all-or-nothing.
|
||||
Link,
|
||||
/// Download the track's audio next to an order-prefixed toml that
|
||||
/// points at it — the captures behavior: incremental into the final
|
||||
/// folder, resumable by name.
|
||||
Download(Downloader),
|
||||
}
|
||||
|
||||
/// How one visited track settled during the fetch phase.
|
||||
enum TrackOutcome {
|
||||
/// Playable data is in place (downloaded now, or already there).
|
||||
Captured,
|
||||
/// The source cannot be captured; a skipped toml records the gap.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// One track discovered by the enumeration phase: what to fetch, where to
|
||||
/// put it, and its listing position (the order prefix).
|
||||
struct TrackEntry {
|
||||
track: Track,
|
||||
dir: PathBuf,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
/// Downloads one track's audio via the provider's stream URL.
|
||||
///
|
||||
/// One shared HTTP client with a connect timeout; each track is bounded by
|
||||
/// [`DOWNLOAD_TRACK_TIMEOUT`] end to end and never retried (a capture is
|
||||
/// re-runnable; resuming re-attempts what is missing). Bodies are fetched
|
||||
/// in bounded [`DOWNLOAD_WINDOW`] ranges and streamed to disk against the
|
||||
/// capture's remaining byte budget.
|
||||
#[derive(Debug)]
|
||||
pub struct Downloader {
|
||||
http: reqwest::Client,
|
||||
window: u64,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
/// Builds the shared HTTP client. Fails only when the TLS backend
|
||||
/// cannot initialize.
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
Self::with_window(DOWNLOAD_WINDOW)
|
||||
}
|
||||
|
||||
/// [`Self::new`] with an explicit window size — the seam the
|
||||
/// window-chaining tests use.
|
||||
pub(crate) fn with_window(window: u64) -> Result<Self, reqwest::Error> {
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(DOWNLOAD_CONNECT_TIMEOUT)
|
||||
.build()?;
|
||||
Ok(Self { http, window })
|
||||
}
|
||||
|
||||
/// Settles one track: audio file first, then the toml pointing at
|
||||
/// it — a toml never exists without its audio. The whole operation is
|
||||
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`].
|
||||
///
|
||||
/// Error messages carry the track's library path, never the stream
|
||||
/// URL (it may embed a token) — reqwest errors are stripped with
|
||||
/// [`reqwest::Error::without_url`].
|
||||
async fn download_track<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
track: &Track,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
bytes_left: &mut u64,
|
||||
) -> Result<TrackOutcome, CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let fetched = tokio::time::timeout(
|
||||
DOWNLOAD_TRACK_TIMEOUT,
|
||||
self.fetch_track(client, track, dir, index, bytes_left),
|
||||
)
|
||||
.await;
|
||||
match fetched {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(CaptureError::Download(format!(
|
||||
"{}: timed out after {}s",
|
||||
track.path,
|
||||
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The unbounded body of [`Self::download_track`]: resolve the stream
|
||||
/// URL, stream the response to disk against the byte budget, then
|
||||
/// write the toml.
|
||||
///
|
||||
/// A track whose source cannot be captured — it is itself skipped, its
|
||||
/// stream fails to resolve, or it resolves to something other than an
|
||||
/// http(s) URL (e.g. a local file playable) — is recorded as a
|
||||
/// **skipped toml** instead of aborting the capture
|
||||
/// (architecture/incremental-captures.md D2): queue and bookmark
|
||||
/// captures mix providers, and one local track must not kill the rest.
|
||||
/// Actual download failures stay fatal for the run.
|
||||
async fn fetch_track<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
track: &Track,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
bytes_left: &mut u64,
|
||||
) -> Result<TrackOutcome, CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let track_path = track.path.as_str();
|
||||
if track.is_skipped {
|
||||
warn!(path = track_path, "recording an already-skipped track");
|
||||
return write_skipped(track, dir, index).await;
|
||||
}
|
||||
let urls = match client.get_urls_for_track(track_path).await {
|
||||
Ok(urls) => urls,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = track_path,
|
||||
"recording uncapturable track as skipped: {err}"
|
||||
);
|
||||
return write_skipped(track, dir, index).await;
|
||||
}
|
||||
};
|
||||
let Some(url) = urls.first() else {
|
||||
warn!(
|
||||
path = track_path,
|
||||
"recording track without a stream url as skipped"
|
||||
);
|
||||
return write_skipped(track, dir, index).await;
|
||||
};
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
// A local file playable (an fs track, or a re-captured
|
||||
// capture): the audio is already on disk, so copy it into the
|
||||
// capture instead of skipping it.
|
||||
return self.copy_local(track, url, dir, index, bytes_left).await;
|
||||
}
|
||||
let download_err = |err: reqwest::Error| {
|
||||
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
||||
};
|
||||
// First bounded window; its status decides the mode. Some CDNs
|
||||
// (googlevideo) 403 plain and open-ended requests, so every
|
||||
// request carries a bounded range; servers that ignore the header
|
||||
// answer 200 with the whole body.
|
||||
let mut start = 0u64;
|
||||
let mut response = self
|
||||
.http
|
||||
.get(url)
|
||||
.header(
|
||||
reqwest::header::RANGE,
|
||||
format!("bytes=0-{}", self.window - 1),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(download_err)?;
|
||||
let windowed = match response.status() {
|
||||
reqwest::StatusCode::PARTIAL_CONTENT => true,
|
||||
reqwest::StatusCode::OK => false,
|
||||
status => {
|
||||
return Err(CaptureError::Download(format!(
|
||||
"{track_path}: HTTP status {status}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let ext = extension_for(content_type.as_deref(), url);
|
||||
let audio_name = audio_file_name(index, &track.title, &ext);
|
||||
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
|
||||
loop {
|
||||
// The window's extent and the resource total, from
|
||||
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
||||
// fallback for the extent).
|
||||
let (window_end, total) = if windowed {
|
||||
let content_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(parse_content_range);
|
||||
match content_range {
|
||||
Some((_, range_end, total)) => (range_end + 1, total),
|
||||
None => (start + response.content_length().unwrap_or(0), None),
|
||||
}
|
||||
} else {
|
||||
(u64::MAX, None)
|
||||
};
|
||||
let mut received = 0u64;
|
||||
while let Some(chunk) = response.chunk().await.map_err(download_err)? {
|
||||
let len = chunk.len() as u64;
|
||||
if len > *bytes_left {
|
||||
return Err(CaptureError::TooLarge("download budget exhausted"));
|
||||
}
|
||||
*bytes_left -= len;
|
||||
received += len;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut audio, &chunk).await?;
|
||||
}
|
||||
if !windowed {
|
||||
break;
|
||||
}
|
||||
start = window_end.max(start + received);
|
||||
match total {
|
||||
Some(total) if start >= total => break,
|
||||
// A short or empty window with no known total: the
|
||||
// resource ended early.
|
||||
_ if received == 0 => break,
|
||||
None if received < self.window => break,
|
||||
_ => {}
|
||||
}
|
||||
response = self
|
||||
.http
|
||||
.get(url)
|
||||
.header(
|
||||
reqwest::header::RANGE,
|
||||
format!("bytes={start}-{}", start + self.window - 1),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(download_err)?;
|
||||
match response.status() {
|
||||
reqwest::StatusCode::PARTIAL_CONTENT => {}
|
||||
// Past the end: everything is on disk.
|
||||
reqwest::StatusCode::RANGE_NOT_SATISFIABLE => break,
|
||||
status => {
|
||||
return Err(CaptureError::Download(format!(
|
||||
"{track_path}: HTTP status {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
||||
drop(audio);
|
||||
|
||||
let text =
|
||||
fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name)).to_toml()?;
|
||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||
Ok(TrackOutcome::Captured)
|
||||
}
|
||||
|
||||
/// Copies an already-local audio file (an fs playable, or a track
|
||||
/// from an existing capture) into the capture folder next to its
|
||||
/// toml. A source that is missing or not a regular file is recorded
|
||||
/// as skipped rather than aborting the run; the copy counts against
|
||||
/// the same byte budget as a download so a capture cannot run away.
|
||||
async fn copy_local(
|
||||
&self,
|
||||
track: &Track,
|
||||
source: &str,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
bytes_left: &mut u64,
|
||||
) -> Result<TrackOutcome, CaptureError> {
|
||||
let track_path = track.path.as_str();
|
||||
let source_path = Path::new(source);
|
||||
match tokio::fs::metadata(source_path).await {
|
||||
Ok(meta) if meta.is_file() => {
|
||||
let len = meta.len();
|
||||
if len > *bytes_left {
|
||||
return Err(CaptureError::TooLarge("download budget exhausted"));
|
||||
}
|
||||
let ext = source_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin");
|
||||
let audio_name = audio_file_name(index, &track.title, ext);
|
||||
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
|
||||
*bytes_left -= len;
|
||||
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
|
||||
.to_toml()?;
|
||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
|
||||
.await?;
|
||||
Ok(TrackOutcome::Captured)
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
path = track_path,
|
||||
"local playable is not a readable file; recording as skipped"
|
||||
);
|
||||
write_skipped(track, dir, index).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
|
||||
fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
|
||||
let rest = value.trim().strip_prefix("bytes ")?;
|
||||
let (range, total) = rest.split_once('/')?;
|
||||
let (start, end) = range.split_once('-')?;
|
||||
let total = match total.trim() {
|
||||
"*" => None,
|
||||
n => Some(n.parse().ok()?),
|
||||
};
|
||||
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||
}
|
||||
|
||||
/// Writes the skipped toml for `track` at listing position `index`,
|
||||
/// overwriting whatever was there.
|
||||
async fn write_skipped(
|
||||
track: &Track,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
) -> Result<TrackOutcome, CaptureError> {
|
||||
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
|
||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||
Ok(TrackOutcome::Skipped)
|
||||
}
|
||||
|
||||
/// Captures the subtree at `source_path` as `dir/<name>/`.
|
||||
///
|
||||
/// Validates `name` ([`fsdy::validate_folder_name`], nothing reserved),
|
||||
/// then enumerates the subtree (caps enforced, total reported) and fetches
|
||||
/// track by track. [`Sink::Link`] builds the whole capture in a hidden
|
||||
/// `.tmp-<name>` sibling and swaps it into place, removing the temp folder
|
||||
/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes
|
||||
/// incrementally into `dir/<name>` itself: satisfied entries are reused,
|
||||
/// uncapturable tracks become skipped tomls, and failures keep everything
|
||||
/// already written (re-run the same name to resume).
|
||||
pub async fn capture_into<C>(
|
||||
dir: &Path,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
caps: Caps,
|
||||
sink: &Sink,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||
match sink {
|
||||
Sink::Link => {
|
||||
let tmp = dir.join(format!(".tmp-{name}"));
|
||||
let written = write_links(client, source_path, &tmp, caps, progress).await;
|
||||
if let Err(err) = written {
|
||||
// Every failure path removes the temp folder: nothing
|
||||
// half-written survives, not even hidden.
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
return Err(err);
|
||||
}
|
||||
let target = dir.join(name);
|
||||
if tokio::fs::try_exists(&target).await? {
|
||||
tokio::fs::remove_dir_all(&target).await?;
|
||||
}
|
||||
tokio::fs::rename(&tmp, &target).await?;
|
||||
Ok(())
|
||||
}
|
||||
Sink::Download(downloader) => {
|
||||
let target = dir.join(name);
|
||||
tokio::fs::create_dir_all(&target).await?;
|
||||
let entries = enumerate(client, source_path, &target, caps).await?;
|
||||
progress.set_total(entries.len());
|
||||
let mut bytes_left = caps.max_bytes;
|
||||
for entry in &entries {
|
||||
if existing_is_satisfied(&entry.dir, entry.index, &entry.track.title).await {
|
||||
progress.track_done();
|
||||
continue;
|
||||
}
|
||||
let outcome = downloader
|
||||
.download_track(
|
||||
client,
|
||||
&entry.track,
|
||||
&entry.dir,
|
||||
entry.index,
|
||||
&mut bytes_left,
|
||||
)
|
||||
.await?;
|
||||
match outcome {
|
||||
TrackOutcome::Captured => progress.track_done(),
|
||||
TrackOutcome::Skipped => progress.track_skipped(),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bookmark walk body: fresh tmp folder, enumerate, one link file per
|
||||
/// track ([`fsdy::TrackFile::from_track`] — skipped source tracks write
|
||||
/// skipped tomls). The caller owns cleanup on error.
|
||||
async fn write_links<C>(
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
tmp: &Path,
|
||||
caps: Caps,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
// A leftover temp folder from a crashed or racing capture is stale.
|
||||
if tokio::fs::try_exists(tmp).await? {
|
||||
tokio::fs::remove_dir_all(tmp).await?;
|
||||
}
|
||||
tokio::fs::create_dir_all(tmp).await?;
|
||||
let entries = enumerate(client, source_path, tmp, caps).await?;
|
||||
progress.set_total(entries.len());
|
||||
for entry in &entries {
|
||||
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
|
||||
let file = entry
|
||||
.dir
|
||||
.join(fsdy::track_file_name(entry.index, &entry.track.title));
|
||||
tokio::fs::write(file, text).await?;
|
||||
progress.track_done();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 1: mirrors the directory structure under `root` and collects
|
||||
/// every track with its target directory and listing index. Iterative
|
||||
/// pre-order over [`ProviderClient::get_lib_node`] (a deep tree must not
|
||||
/// overflow the stack); existing directories are reused
|
||||
/// (`create_dir_all`), which is what makes download captures resumable. A
|
||||
/// `source_path` that is a track enumerates as a single entry. Enforces
|
||||
/// `max_dirs`/`max_tracks`.
|
||||
async fn enumerate<C>(
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
root: &Path,
|
||||
caps: Caps,
|
||||
) -> Result<Vec<TrackEntry>, CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
if client.is_track_path(source_path) {
|
||||
let track = client
|
||||
.get_metadata_for_track(source_path)
|
||||
.await
|
||||
.map_err(|err| CaptureError::BadSource(format!("{source_path}: {err}")))?;
|
||||
return Ok(vec![TrackEntry {
|
||||
track,
|
||||
dir: root.to_path_buf(),
|
||||
index: 0,
|
||||
}]);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut dirs = 1usize;
|
||||
let mut tracks = 0usize;
|
||||
let mut worklist: Vec<(String, PathBuf)> = vec![(source_path.to_string(), root.to_path_buf())];
|
||||
while let Some((lib_path, dir)) = worklist.pop() {
|
||||
let node = client
|
||||
.get_lib_node(&lib_path)
|
||||
.await
|
||||
.map_err(|err| CaptureError::BadSource(format!("{lib_path}: {err}")))?;
|
||||
for (index, track) in node.tracks.iter().enumerate() {
|
||||
tracks += 1;
|
||||
if tracks > caps.max_tracks {
|
||||
return Err(CaptureError::TooLarge("too many tracks"));
|
||||
}
|
||||
entries.push(TrackEntry {
|
||||
track: track.clone(),
|
||||
dir: dir.clone(),
|
||||
index,
|
||||
});
|
||||
}
|
||||
for (index, child) in node.children.iter().enumerate() {
|
||||
dirs += 1;
|
||||
if dirs > caps.max_dirs {
|
||||
return Err(CaptureError::TooLarge("too many directories"));
|
||||
}
|
||||
let child_dir = dir.join(fsdy::dir_name(index, &child.title));
|
||||
tokio::fs::create_dir_all(&child_dir).await?;
|
||||
worklist.push((child.path.clone(), child_dir));
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Whether the entry for track `title` at listing position `index` in
|
||||
/// `dir` already has playable data: a parseable toml whose playable is not
|
||||
/// skipped, and — for a `file` playable — whose audio file exists.
|
||||
/// Anything else (missing, broken, skipped, audio gone) is re-captured.
|
||||
async fn existing_is_satisfied(dir: &Path, index: usize, title: &str) -> bool {
|
||||
let toml_path = dir.join(fsdy::track_file_name(index, title));
|
||||
let Ok(text) = tokio::fs::read_to_string(&toml_path).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(file) = fsdy::TrackFile::parse(&text) else {
|
||||
return false;
|
||||
};
|
||||
match file.playable() {
|
||||
Ok(fsdy::Playable::File(target)) => {
|
||||
let absolute = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
dir.join(target)
|
||||
};
|
||||
tokio::fs::try_exists(absolute).await.unwrap_or(false)
|
||||
}
|
||||
Ok(fsdy::Playable::Skipped) => false,
|
||||
// A url/link playable was not written by this store, but whoever
|
||||
// put it there gave the entry playable data — keep it.
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the audio file extension: the response `Content-Type` first
|
||||
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
|
||||
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
|
||||
/// extension, then `bin` — the player probes by content, the extension is
|
||||
/// a hint.
|
||||
fn extension_for(content_type: Option<&str>, url: &str) -> String {
|
||||
let mapped = content_type
|
||||
.and_then(|ct| ct.split(';').next())
|
||||
.map(|essence| essence.trim().to_ascii_lowercase())
|
||||
.and_then(|essence| match essence.as_str() {
|
||||
"audio/flac" | "audio/x-flac" => Some("flac"),
|
||||
"audio/mp4" | "audio/m4a" | "audio/x-m4a" => Some("m4a"),
|
||||
"audio/mpeg" | "audio/mp3" => Some("mp3"),
|
||||
"audio/ogg" => Some("ogg"),
|
||||
"audio/wav" | "audio/x-wav" => Some("wav"),
|
||||
// YouTube bestaudio is usually opus in webm; googlevideo
|
||||
// URLs carry no path extension to fall back on.
|
||||
"audio/webm" | "video/webm" => Some("webm"),
|
||||
_ => None,
|
||||
});
|
||||
match mapped.or_else(|| url_extension(url)) {
|
||||
Some(ext) => ext.to_string(),
|
||||
None => "bin".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension of a URL's last path segment (query and fragment
|
||||
/// stripped), when it looks like one: short and alphanumeric.
|
||||
fn url_extension(url: &str) -> Option<&str> {
|
||||
let path = url.split(['?', '#']).next()?;
|
||||
let segment = path.rsplit('/').next()?;
|
||||
let (stem, ext) = segment.rsplit_once('.')?;
|
||||
let plausible = !stem.is_empty()
|
||||
&& !ext.is_empty()
|
||||
&& ext.len() <= 5
|
||||
&& ext.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
plausible.then_some(ext)
|
||||
}
|
||||
|
||||
/// The audio file name for track `index` titled `title` with `ext`, next
|
||||
/// to its toml: `NNNN <title>.<ext>` through the same sanitizer as the
|
||||
/// toml name, so the pair sorts together — and what the toml's relative
|
||||
/// `file` playable points at.
|
||||
fn audio_file_name(index: usize, title: &str, ext: &str) -> String {
|
||||
format!("{}.{ext}", fsdy::dir_name(index, title))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extension_prefers_content_type_then_url_then_bin() {
|
||||
for (ct, ext) in [
|
||||
("audio/flac", "flac"),
|
||||
("audio/mp4", "m4a"),
|
||||
("audio/m4a", "m4a"),
|
||||
("audio/mpeg", "mp3"),
|
||||
("audio/ogg", "ogg"),
|
||||
("audio/wav", "wav"),
|
||||
("audio/webm", "webm"),
|
||||
] {
|
||||
assert_eq!(extension_for(Some(ct), "https://x.test/s"), ext);
|
||||
}
|
||||
// Content-Type parameters must not confuse the mapping.
|
||||
assert_eq!(
|
||||
extension_for(Some("audio/flac; charset=binary"), "https://x.test/s"),
|
||||
"flac"
|
||||
);
|
||||
// Unknown or missing types fall back to the URL path's extension…
|
||||
assert_eq!(
|
||||
extension_for(None, "https://x.test/media/track.m4a?token=abc"),
|
||||
"m4a"
|
||||
);
|
||||
assert_eq!(
|
||||
extension_for(Some("application/octet-stream"), "https://x.test/a.flac"),
|
||||
"flac"
|
||||
);
|
||||
// …and to `bin` when the URL has none either.
|
||||
assert_eq!(extension_for(None, "https://x.test/stream"), "bin");
|
||||
assert_eq!(extension_for(None, "not a url"), "bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_range_parses_totals_and_wildcards() {
|
||||
assert_eq!(
|
||||
parse_content_range("bytes 0-1023/7831134"),
|
||||
Some((0, 1023, Some(7831134)))
|
||||
);
|
||||
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, 9, None)));
|
||||
assert_eq!(parse_content_range("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_files_pair_with_their_toml_names() {
|
||||
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
|
||||
// audio file sorts right next to its toml.
|
||||
assert_eq!(
|
||||
audio_file_name(0, "We Will Rock You", "flac"),
|
||||
"0001 We Will Rock You.flac"
|
||||
);
|
||||
assert_eq!(audio_file_name(11, "a/b", "mp3"), "0012 a_b.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_counts_skipped_toward_done_and_finishes_once() {
|
||||
let (tx, rx) = flume::bounded(16);
|
||||
let progress = Progress::new("faves", true, tx);
|
||||
progress.set_total(3);
|
||||
progress.track_done();
|
||||
progress.track_skipped();
|
||||
|
||||
let events: Vec<CaptureProgress> = rx.drain().collect();
|
||||
let last = events.last().expect("events published");
|
||||
assert_eq!(last.tracks_total, 3);
|
||||
// Skipped counts toward done: the ratio reaches total on success.
|
||||
assert_eq!(last.tracks_done, 2);
|
||||
assert_eq!(last.tracks_skipped, 1);
|
||||
assert!(events.iter().all(|e| !e.finished));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn progress_terminal_event_carries_the_error() {
|
||||
let (tx, rx) = flume::bounded(16);
|
||||
let progress = Progress::new("faves", true, tx);
|
||||
progress.finish(Some("boom".to_string())).await;
|
||||
let event = rx.recv_async().await.expect("terminal event");
|
||||
assert!(event.finished);
|
||||
assert_eq!(event.error, "boom");
|
||||
// A silent reporter must not panic anywhere.
|
||||
let silent = Progress::silent("x", false);
|
||||
silent.set_total(1);
|
||||
silent.track_done();
|
||||
silent.finish(None).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,783 @@
|
|||
//! Downloaded library subtrees ("captures") on disk
|
||||
//! (see `architecture/captures.md` and
|
||||
//! `architecture/incremental-captures.md`).
|
||||
//!
|
||||
//! Every capture is a folder under `<config>/crabidy/captures/` that
|
||||
//! mirrors the captured subtree like a bookmark, except each track's audio
|
||||
//! is **downloaded** next to its order-prefixed `*.cbd-track.toml`, and the
|
||||
//! toml's playable is a relative `file` pointing at it — replaying a
|
||||
//! capture needs no provider round trip. Tracks whose source cannot be
|
||||
//! captured are recorded as *skipped* tomls. The same directory is mounted
|
||||
//! read-only into the library as `/captures` by an `fsdy` instance (with
|
||||
//! editable top-level folders); this module is the only writer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crabidy_core::ProviderClient;
|
||||
|
||||
use crate::capture::{Caps, CaptureError, Progress};
|
||||
|
||||
/// The library mount point of the captures directory.
|
||||
pub const CAPTURES_PROVIDER_ROOT: &str = "/captures";
|
||||
|
||||
/// The captures directory: `captures/` inside the crabidy config
|
||||
/// directory. `None` when the platform has no config directory.
|
||||
pub fn captures_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("crabidy").join("captures"))
|
||||
}
|
||||
|
||||
/// Writes download captures. All audio is fetched through one shared HTTP
|
||||
/// client. Captures are **incremental**: the walk writes into the final
|
||||
/// folder, reuses entries that already have their audio, and keeps
|
||||
/// everything written so far when a download fails — capturing the same
|
||||
/// name again resumes (and completes previously skipped entries where the
|
||||
/// source became capturable).
|
||||
#[derive(Debug)]
|
||||
pub struct CaptureStore {
|
||||
dir: PathBuf,
|
||||
sink: crate::capture::Sink,
|
||||
}
|
||||
|
||||
impl CaptureStore {
|
||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||
/// missing, and builds the shared HTTP client.
|
||||
pub async fn open(dir: PathBuf) -> Result<Self, CaptureError> {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let downloader = crate::capture::Downloader::new()
|
||||
.map_err(|err| CaptureError::Download(format!("cannot build http client: {err}")))?;
|
||||
Ok(Self {
|
||||
dir,
|
||||
sink: crate::capture::Sink::Download(downloader),
|
||||
})
|
||||
}
|
||||
|
||||
/// The store directory (what the `/captures` provider instance
|
||||
/// mounts).
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Validates a capture request without writing anything: the name must
|
||||
/// be a legal folder name and the capture **root** must opt in
|
||||
/// (`architecture/captures.md` D4 — a directory source must report
|
||||
/// `is_downloadable`, a track source's parent node must). The
|
||||
/// accept-then-stream RPC replies after this and runs
|
||||
/// [`Self::capture`] detached (architecture/incremental-captures.md
|
||||
/// D4).
|
||||
pub async fn validate<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||
source_allows_download(client, source_path).await
|
||||
}
|
||||
|
||||
/// Captures the subtree at `source_path` as the capture `name`,
|
||||
/// downloading every track's audio — **incrementally**: an existing
|
||||
/// capture of that name is resumed, not overwritten (satisfied entries
|
||||
/// are reused, skipped and broken ones re-attempted). Reports through
|
||||
/// `progress` (non-terminal events only; the caller sends the terminal
|
||||
/// one).
|
||||
///
|
||||
/// The source must pass [`Self::validate`]. The walk, caps
|
||||
/// ([`crate::capture::DOWNLOAD_CAPS`]), reuse rule, and skipped tomls
|
||||
/// are [`crate::capture::capture_into`]'s.
|
||||
pub async fn capture<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
self.capture_with_caps(
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
crate::capture::DOWNLOAD_CAPS,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||
async fn capture_with_caps<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
caps: Caps,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
source_allows_download(client, source_path).await?;
|
||||
crate::capture::capture_into(
|
||||
&self.dir,
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
caps,
|
||||
&self.sink,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that the capture root allows downloads: the node at
|
||||
/// `source_path` (or, for a track path, its parent node) must set
|
||||
/// `is_downloadable`. An unreadable root is [`CaptureError::BadSource`].
|
||||
async fn source_allows_download<C>(client: &C, source_path: &str) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let node_path = if client.is_track_path(source_path) {
|
||||
crabidy_core::parent_path(source_path).unwrap_or(source_path)
|
||||
} else {
|
||||
source_path
|
||||
};
|
||||
let node = client
|
||||
.get_lib_node(node_path)
|
||||
.await
|
||||
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
|
||||
if node.is_downloadable {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CaptureError::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::{Caps, DOWNLOAD_CAPS};
|
||||
use async_trait::async_trait;
|
||||
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
||||
use crabidy_core::ProviderError;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// Serves every request with one fixed response and returns a URL for
|
||||
/// it. Minimal HTTP/1.1 on a loopback socket — enough for reqwest.
|
||||
async fn serve(status: &'static str, content_type: &'static str, body: Vec<u8>) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("test server addr");
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
// Read until the header terminator; the request itself
|
||||
// is irrelevant.
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(head.as_bytes()).await;
|
||||
let _ = sock.write_all(&body).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/stream")
|
||||
}
|
||||
|
||||
/// Like [`serve`], but the server enforces bounded ranges the way
|
||||
/// googlevideo does: ranged requests up to `max_window` bytes get
|
||||
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
|
||||
/// oversized) gets `403`.
|
||||
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("test server addr");
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf).to_lowercase();
|
||||
let range = request
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||
.and_then(|spec| {
|
||||
let (start, end) = spec.trim().split_once('-')?;
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let end: u64 = end.parse().ok()?;
|
||||
Some((start, end))
|
||||
});
|
||||
let total = body.len() as u64;
|
||||
let response = match range {
|
||||
Some((start, end)) if start < total && end - start < max_window => {
|
||||
let end = end.min(total - 1);
|
||||
let slice = &body[start as usize..=end as usize];
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Type: {content_type}\r\n\
|
||||
Content-Range: bytes {start}-{end}/{total}\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
slice.len()
|
||||
)
|
||||
.into_bytes();
|
||||
head.extend_from_slice(slice);
|
||||
head
|
||||
}
|
||||
Some((start, _)) if start >= total => format!(
|
||||
"HTTP/1.1 416 Range Not Satisfiable\r\n\
|
||||
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
|
||||
Connection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes(),
|
||||
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
.to_vec(),
|
||||
};
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/stream")
|
||||
}
|
||||
|
||||
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
|
||||
/// stream URLs are the test server's, and a node-level download
|
||||
/// blessing toggle.
|
||||
#[derive(Debug)]
|
||||
struct MockProvider {
|
||||
urls: HashMap<String, String>,
|
||||
downloadable: bool,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(urls: &[(&str, &str)], downloadable: bool) -> Self {
|
||||
Self {
|
||||
urls: urls
|
||||
.iter()
|
||||
.map(|(p, u)| (p.to_string(), u.to_string()))
|
||||
.collect(),
|
||||
downloadable,
|
||||
}
|
||||
}
|
||||
|
||||
fn track(&self, path: &str) -> Track {
|
||||
let title = match path {
|
||||
"/mock/a/1" => "one",
|
||||
"/mock/a/2" => "two",
|
||||
other => other,
|
||||
};
|
||||
Track {
|
||||
path: path.to_string(),
|
||||
artist: "mock".to_string(),
|
||||
title: title.to_string(),
|
||||
duration: Some(10),
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderClient for MockProvider {
|
||||
async fn init(_s: &str) -> Result<Self, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
fn settings(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
fn is_track_path(&self, path: &str) -> bool {
|
||||
path.starts_with("/mock/a/") && path.len() > "/mock/a/".len()
|
||||
}
|
||||
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||
self.urls
|
||||
.get(track_path)
|
||||
.map(|u| vec![u.clone()])
|
||||
.ok_or(ProviderError::FetchError)
|
||||
}
|
||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||
if !self.is_track_path(track_path) {
|
||||
return Err(ProviderError::MalformedPath);
|
||||
}
|
||||
Ok(self.track(track_path))
|
||||
}
|
||||
fn get_lib_root(&self) -> LibraryNode {
|
||||
LibraryNode::new()
|
||||
}
|
||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||
let mut node = LibraryNode::new();
|
||||
node.path = path.to_string();
|
||||
node.is_downloadable = self.downloadable;
|
||||
match path {
|
||||
"/mock" => {
|
||||
node.title = "mock".to_string();
|
||||
node.children = vec![LibraryNodeChild {
|
||||
is_downloadable: self.downloadable,
|
||||
..LibraryNodeChild::new("/mock/a".to_string(), "a".to_string(), true)
|
||||
}];
|
||||
}
|
||||
"/mock/a" => {
|
||||
node.title = "a".to_string();
|
||||
node.is_queable = true;
|
||||
node.tracks = vec![self.track("/mock/a/1"), self.track("/mock/a/2")];
|
||||
}
|
||||
_ => return Err(ProviderError::MalformedPath),
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
async fn create_lib_node(
|
||||
&self,
|
||||
_parent_path: &str,
|
||||
_title: &str,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn rename_lib_node(
|
||||
&self,
|
||||
_path: &str,
|
||||
_new_title: &str,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
}
|
||||
|
||||
async fn store() -> (CaptureStore, TempDir) {
|
||||
let dir = TempDir::new().expect("store tempdir");
|
||||
let store = CaptureStore::open(dir.path().join("captures"))
|
||||
.await
|
||||
.expect("open creates the directory");
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
fn visible(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = fs::read_dir(dir)
|
||||
.expect("dir")
|
||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
/// A silent progress reporter for tests that do not assert on events.
|
||||
fn silent() -> Progress {
|
||||
Progress::silent("test", true)
|
||||
}
|
||||
|
||||
/// Parses the capture entry's toml and returns its playable.
|
||||
fn playable_of(dir: &Path, name: &str) -> fsdy::Playable {
|
||||
let text = fs::read_to_string(dir.join(name)).expect("toml");
|
||||
fsdy::TrackFile::parse(&text)
|
||||
.expect("parses")
|
||||
.playable()
|
||||
.expect("playable")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_capture_writes_audio_next_to_pointing_tomls() {
|
||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let root = store.dir().join("faves");
|
||||
assert_eq!(
|
||||
visible(&root),
|
||||
vec![
|
||||
"0001 one.cbd-track.toml".to_string(),
|
||||
"0001 one.flac".into(),
|
||||
"0002 two.cbd-track.toml".into(),
|
||||
"0002 two.flac".into(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||
b"flacbytes"
|
||||
);
|
||||
// The toml points at its sibling with a *relative* file playable.
|
||||
assert_eq!(
|
||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||
fsdy::Playable::File("0002 two.flac".into())
|
||||
);
|
||||
|
||||
// Replay: the capture resolves through a /captures instance and the
|
||||
// audio resolves to the absolute sibling path — no provider round
|
||||
// trip left.
|
||||
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||
.expect("captures instance");
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||
captures
|
||||
.resolve_tracks_into("/captures/faves", chunk_tx)
|
||||
.await
|
||||
.expect("resolve");
|
||||
let tracks: Vec<Track> = chunk_rx.into_iter().flatten().collect();
|
||||
assert_eq!(tracks.len(), 2);
|
||||
let urls = captures
|
||||
.get_urls_for_track(&tracks[0].path)
|
||||
.await
|
||||
.expect("urls");
|
||||
assert_eq!(urls, vec![root.join("0001 one.flac").display().to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_requires_the_root_download_blessing() {
|
||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], false);
|
||||
let (store, _dir) = store().await;
|
||||
// validate() is what the accept-then-stream RPC checks up front...
|
||||
assert!(matches!(
|
||||
store.validate(&mock, "/mock/a", "faves").await,
|
||||
Err(CaptureError::Unsupported)
|
||||
));
|
||||
// ...and the walk itself re-checks, for both node and track roots.
|
||||
assert!(matches!(
|
||||
store.capture(&mock, "/mock/a", "faves", &silent()).await,
|
||||
Err(CaptureError::Unsupported)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.capture(&mock, "/mock/a/1", "faves", &silent()).await,
|
||||
Err(CaptureError::Unsupported)
|
||||
));
|
||||
assert_eq!(fs::read_dir(store.dir()).expect("store dir").count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capturing_a_single_blessed_track_writes_one_pair() {
|
||||
let url = serve("200 OK", "audio/mpeg", b"mp3bytes".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a/1", "just one", &silent())
|
||||
.await
|
||||
.expect("capture track");
|
||||
assert_eq!(
|
||||
visible(&store.dir().join("just one")),
|
||||
vec!["0001 one.cbd-track.toml".to_string(), "0001 one.mp3".into()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_failure_keeps_progress_and_resuming_completes() {
|
||||
let ok = serve("200 OK", "audio/flac", b"first".to_vec()).await;
|
||||
let gone = serve("404 Not Found", "text/plain", Vec::new()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &gone)], true);
|
||||
let (store, _dir) = store().await;
|
||||
let err = store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect_err("a failed download aborts the run");
|
||||
assert!(matches!(err, CaptureError::Download(_)), "got {err:?}");
|
||||
// Incremental: what downloaded before the failure survives
|
||||
// (architecture/incremental-captures.md D2).
|
||||
let root = store.dir().join("faves");
|
||||
assert!(root.join("0001 one.cbd-track.toml").exists());
|
||||
assert!(root.join("0001 one.flac").exists());
|
||||
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
||||
|
||||
// Resume with a healthy source: the satisfied entry is reused (the
|
||||
// sentinel content is not re-downloaded), the missing one arrives.
|
||||
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
||||
let fixed = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &ok)], true);
|
||||
store
|
||||
.capture(&fixed, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("resume completes");
|
||||
assert_eq!(
|
||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||
b"sentinel",
|
||||
"satisfied entries must not be re-downloaded"
|
||||
);
|
||||
assert!(root.join("0002 two.cbd-track.toml").exists());
|
||||
assert_eq!(
|
||||
fs::read(root.join("0002 two.flac")).expect("audio"),
|
||||
b"first"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_capture_enforces_its_caps() {
|
||||
let url = serve("200 OK", "audio/flac", b"0123456789".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
// Byte budget: two 10-byte bodies against a 15-byte budget. The
|
||||
// first track fits, the second trips the budget — and survives as
|
||||
// partial progress (no toml, so a resume re-downloads it).
|
||||
let caps = Caps {
|
||||
max_bytes: 15,
|
||||
..DOWNLOAD_CAPS
|
||||
};
|
||||
let err = store
|
||||
.capture_with_caps(&mock, "/mock/a", "big", caps, &silent())
|
||||
.await
|
||||
.expect_err("over the byte budget");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
||||
let root = store.dir().join("big");
|
||||
assert!(root.join("0001 one.cbd-track.toml").exists());
|
||||
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
||||
// Track cap: enumeration fails before anything is fetched.
|
||||
let caps = Caps {
|
||||
max_tracks: 1,
|
||||
..DOWNLOAD_CAPS
|
||||
};
|
||||
let err = store
|
||||
.capture_with_caps(&mock, "/mock/a", "big2", caps, &silent())
|
||||
.await
|
||||
.expect_err("over the track cap");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
||||
assert_eq!(visible(&store.dir().join("big2")), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
|
||||
// A queue-like source: a downloadable fsdy instance whose folder
|
||||
// mixes an http track with a local-file track — exactly a
|
||||
// persisted queue linking tidal and fs entries. Both must end up
|
||||
// with local audio: the web one downloaded, the fs one copied.
|
||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||
let src = TempDir::new().expect("source tempdir");
|
||||
let mix = src.path().join("mix");
|
||||
fs::create_dir_all(&mix).expect("mkdir");
|
||||
fs::write(
|
||||
mix.join("01 web.cbd-track.toml"),
|
||||
format!("title = \"web\"\n[playable]\nurl = {url:?}\n"),
|
||||
)
|
||||
.expect("write");
|
||||
fs::write(mix.join("audio.flac"), b"local").expect("write");
|
||||
fs::write(
|
||||
mix.join("02 local.cbd-track.toml"),
|
||||
"title = \"local\"\n[playable]\nfile = \"audio.flac\"\n",
|
||||
)
|
||||
.expect("write");
|
||||
let source = fsdy::Client::new("/queues", src.path().to_path_buf())
|
||||
.expect("source instance")
|
||||
.with_downloadable_nodes();
|
||||
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&source, "/queues/mix", "mixed", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
// Both tracks captured with audio next to their toml: the web
|
||||
// track downloaded, the local track copied in.
|
||||
let root = store.dir().join("mixed");
|
||||
assert_eq!(
|
||||
visible(&root),
|
||||
vec![
|
||||
"0001 web.cbd-track.toml".to_string(),
|
||||
"0001 web.flac".into(),
|
||||
"0002 local.cbd-track.toml".into(),
|
||||
"0002 local.flac".into(),
|
||||
]
|
||||
);
|
||||
match playable_of(&root, "0002 local.cbd-track.toml") {
|
||||
fsdy::Playable::File(rel) => {
|
||||
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
|
||||
}
|
||||
other => panic!("expected the local track copied, got {other:?}"),
|
||||
}
|
||||
// Neither lists as skipped.
|
||||
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||
.expect("captures instance");
|
||||
let node = captures
|
||||
.get_lib_node("/captures/mixed")
|
||||
.await
|
||||
.expect("node");
|
||||
assert_eq!(node.tracks.len(), 2);
|
||||
assert!(node.tracks.iter().all(|t| !t.is_skipped));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skipped_entries_are_reattempted_on_resume() {
|
||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||
// Track two has no stream URL: recorded as skipped.
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("capture with a skipped entry");
|
||||
let root = store.dir().join("faves");
|
||||
assert_eq!(
|
||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||
fsdy::Playable::Skipped
|
||||
);
|
||||
|
||||
// The source became capturable: re-capturing the same name
|
||||
// completes the skipped entry and reuses the satisfied one.
|
||||
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
||||
let fixed = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
store
|
||||
.capture(&fixed, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("resume");
|
||||
assert_eq!(
|
||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||
fsdy::Playable::File("0002 two.flac".into())
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||
b"sentinel"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_validates_names_and_merges_into_existing() {
|
||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
assert!(matches!(
|
||||
store.validate(&mock, "/mock/a", "a/b").await,
|
||||
Err(CaptureError::InvalidName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.capture(&mock, "/mock/a", "a/b", &silent()).await,
|
||||
Err(CaptureError::InvalidName(_))
|
||||
));
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("first capture");
|
||||
// Capturing a smaller source into the same name merges: the
|
||||
// single-track capture reuses its entry, the rest stays.
|
||||
store
|
||||
.capture(&mock, "/mock/a/1", "faves", &silent())
|
||||
.await
|
||||
.expect("merge");
|
||||
assert_eq!(
|
||||
visible(&store.dir().join("faves")),
|
||||
vec![
|
||||
"0001 one.cbd-track.toml".to_string(),
|
||||
"0001 one.flac".into(),
|
||||
"0002 two.cbd-track.toml".into(),
|
||||
"0002 two.flac".into(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_chain_bounded_windows_on_strict_cdns() {
|
||||
// A googlevideo-style server: only bounded ranges under 16 bytes
|
||||
// are served; plain or oversized requests are 403. A downloader
|
||||
// with a 10-byte window must fetch the 100-byte body completely.
|
||||
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
||||
let url = serve_ranged("audio/flac", body.clone(), 16).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let dir = TempDir::new().expect("store tempdir");
|
||||
let store_dir = dir.path().join("captures");
|
||||
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
|
||||
let store = CaptureStore {
|
||||
dir: store_dir,
|
||||
sink: crate::capture::Sink::Download(
|
||||
crate::capture::Downloader::with_window(10).expect("downloader"),
|
||||
),
|
||||
};
|
||||
store
|
||||
.capture(&mock, "/mock/a/1", "windowed", &silent())
|
||||
.await
|
||||
.expect("windowed capture");
|
||||
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
|
||||
assert_eq!(audio, body, "all windows stitched in order");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_reports_progress_totals_and_skips() {
|
||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||
// Track two is uncapturable: one done, one skipped, total two.
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
let (tx, rx) = flume::bounded(64);
|
||||
let progress = Progress::new("faves", true, tx);
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &progress)
|
||||
.await
|
||||
.expect("capture");
|
||||
progress.finish(None).await;
|
||||
let events: Vec<_> = rx.drain().collect();
|
||||
assert!(events.iter().any(|e| e.tracks_total == 2 && !e.finished));
|
||||
let last = events.last().expect("terminal event");
|
||||
assert!(last.finished);
|
||||
assert!(last.error.is_empty());
|
||||
assert_eq!(last.tracks_done, 2, "skipped counts toward done");
|
||||
assert_eq!(last.tracks_skipped, 1);
|
||||
assert!(last.download);
|
||||
assert_eq!(last.name, "faves");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_playables_are_copied_not_skipped() {
|
||||
// An fs-style playable: the provider resolves the track to a
|
||||
// local file path (not an http URL). The capture must copy that
|
||||
// audio in, not record the track as skipped.
|
||||
let src = TempDir::new().expect("source dir");
|
||||
let audio = src.path().join("song.flac");
|
||||
fs::write(&audio, b"flacdata").expect("write source audio");
|
||||
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
|
||||
let (store, dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let capture_dir = dir.path().join("captures").join("faves");
|
||||
// Track one ("one"): a File playable whose audio was copied in
|
||||
// next to the toml, with the source extension and contents.
|
||||
let one_toml = fsdy::track_file_name(0, "one");
|
||||
match playable_of(&capture_dir, &one_toml) {
|
||||
fsdy::Playable::File(rel) => {
|
||||
let copied = capture_dir.join(&rel);
|
||||
assert!(copied.exists(), "audio copied next to the toml");
|
||||
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
|
||||
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
|
||||
}
|
||||
other => panic!("expected a copied File playable, got {other:?}"),
|
||||
}
|
||||
// Track two ("two") has no source and is still recorded skipped.
|
||||
let two_toml = fsdy::track_file_name(1, "two");
|
||||
assert!(matches!(
|
||||
playable_of(&capture_dir, &two_toml),
|
||||
fsdy::Playable::Skipped
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,308 @@
|
|||
use crabidy_core::proto::crabidy::{Queue, Track};
|
||||
pub mod auth;
|
||||
pub mod bookmark_store;
|
||||
#[cfg(feature = "web-ui")]
|
||||
pub mod web;
|
||||
|
||||
pub mod capture;
|
||||
pub mod capture_store;
|
||||
pub mod playback;
|
||||
pub mod provider;
|
||||
pub mod queue_store;
|
||||
pub mod rpc;
|
||||
pub mod settings;
|
||||
pub mod spectrum;
|
||||
|
||||
use audio_player::PlayerMessage;
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
||||
Track,
|
||||
};
|
||||
use crabidy_core::{ProviderClient, ProviderError};
|
||||
use rand::{rng, seq::SliceRandom};
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, error};
|
||||
use tracing::{debug, error, info, instrument, warn, Span};
|
||||
|
||||
/// The gRPC listen address of the server.
|
||||
pub const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||||
|
||||
/// Builds and runs the whole server stack on `addr`: provider
|
||||
/// orchestrator, queue persistence, playback loop, player message
|
||||
/// forwarder, and the tonic gRPC service. Runs until the server is shut
|
||||
/// down or fails.
|
||||
///
|
||||
/// Extracted from the `crabidy-server` binary so the bundled `cbd`
|
||||
/// binary can host the same server in-process
|
||||
/// (architecture/cbd-bundle.md D1). Errors are returned, never
|
||||
/// panicked: a failed provider init or an occupied port is the
|
||||
/// caller's decision.
|
||||
pub async fn serve(
|
||||
addr: std::net::SocketAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Auth first: a malformed crabidy-server.toml must abort startup
|
||||
// instead of running an intended-to-be-locked server open
|
||||
// (architecture/roles-auth.md). A missing file runs open.
|
||||
let config_dir = dirs::config_dir()
|
||||
.map(|d| d.join("crabidy"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let server_settings = settings::ServerSettings::load(&config_dir)?;
|
||||
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
|
||||
if authenticator.enabled() {
|
||||
info!("role authorization enabled");
|
||||
}
|
||||
|
||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||
let orchestrator = provider::ProviderOrchestrator::init("")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("failed to init provider orchestrator: {err}");
|
||||
err
|
||||
})?;
|
||||
|
||||
// Queue persistence is optional: without a usable queues directory the
|
||||
// server runs with an in-memory queue only.
|
||||
let queue_store = match queue_store::queues_dir() {
|
||||
Some(dir) => match queue_store::QueueStore::open(dir).await {
|
||||
Ok(store) => Some(Arc::new(store)),
|
||||
Err(err) => {
|
||||
warn!("queue persistence disabled: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("queue persistence disabled: no config directory");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let playback = playback::Playback::new(
|
||||
update_tx.clone(),
|
||||
orchestrator.provider_tx.clone(),
|
||||
queue_store,
|
||||
);
|
||||
// Reload the persisted current queue before anything can observe or
|
||||
// mutate state; never starts playback.
|
||||
playback.restore_current().await;
|
||||
|
||||
let playback_tx = playback.playback_tx.clone();
|
||||
let player_msg = playback.player.messages.clone();
|
||||
|
||||
std::thread::spawn(|| {
|
||||
poll_play_bus(player_msg, playback_tx);
|
||||
});
|
||||
info!("player message forwarder started");
|
||||
|
||||
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
||||
|
||||
let crabidy_service = rpc::RpcService::new(
|
||||
update_tx,
|
||||
playback.playback_tx.clone(),
|
||||
orchestrator.provider_tx.clone(),
|
||||
);
|
||||
orchestrator.run();
|
||||
info!("provider orchestrator started");
|
||||
playback.run();
|
||||
info!("playback started");
|
||||
|
||||
let router = build_router(crabidy_service, authenticator);
|
||||
|
||||
info!(%addr, "grpc server listening");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Composes the one axum router that serves everything on one port: the
|
||||
/// gRPC service (native HTTP/2 for the TUI *and*, with `web-ui`,
|
||||
/// gRPC-web for the browser through the tonic-web layer) plus, with
|
||||
/// `web-ui`, the embedded web client as the fallback route
|
||||
/// (architecture/web-client.md).
|
||||
///
|
||||
/// The auth layer wraps only the gRPC route — its default-deny is for
|
||||
/// RPC methods; the app shell itself is public, like any login page.
|
||||
/// Kept separate from [`serve`] so the routing/auth composition is
|
||||
/// testable without a live provider backend.
|
||||
pub fn build_router(
|
||||
crabidy_service: rpc::RpcService,
|
||||
authenticator: Arc<auth::Authenticator>,
|
||||
) -> axum::Router {
|
||||
let builder = tower::ServiceBuilder::new().layer(auth::AuthLayer::new(authenticator));
|
||||
#[cfg(feature = "web-ui")]
|
||||
let builder = builder.layer(tonic_web::GrpcWebLayer::new());
|
||||
let grpc = builder.service(CrabidyServiceServer::new(crabidy_service));
|
||||
let router = axum::Router::new().route_service(
|
||||
&format!(
|
||||
"/{}/{{*method}}",
|
||||
<CrabidyServiceServer<rpc::RpcService> as tonic::server::NamedService>::NAME
|
||||
),
|
||||
grpc,
|
||||
);
|
||||
#[cfg(feature = "web-ui")]
|
||||
let router = router.fallback(web::serve_asset);
|
||||
router
|
||||
}
|
||||
|
||||
/// The spectrum FFT loop (architecture/spectrum.md): ~20 fps, snapshots
|
||||
/// the player's sample tap, folds it into frequency bars, and
|
||||
/// broadcasts them. Cheap and gated: it skips ticks with no stream
|
||||
/// subscribers, and only recomputes when the tap advanced since the
|
||||
/// last tick (audio is flowing), emitting a single zero frame when
|
||||
/// playback goes idle so the bars fall rather than freeze.
|
||||
fn spawn_spectrum_task(
|
||||
tap: std::sync::Arc<audio_player::SpectrumTap>,
|
||||
update_tx: tokio::sync::broadcast::Sender<
|
||||
crabidy_core::proto::crabidy::get_update_stream_response::Update,
|
||||
>,
|
||||
) {
|
||||
use crabidy_core::proto::crabidy::{get_update_stream_response::Update, SpectrumFrame};
|
||||
|
||||
const FPS: u64 = 20;
|
||||
tokio::spawn(async move {
|
||||
let mut analyzer = spectrum::SpectrumAnalyzer::new(audio_player::SPECTRUM_WINDOW);
|
||||
let mut last_count = tap.frame_count();
|
||||
let mut was_active = false;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000 / FPS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// Nobody watching: do no work.
|
||||
if update_tx.receiver_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
let count = tap.frame_count();
|
||||
if count != last_count {
|
||||
last_count = count;
|
||||
if !was_active {
|
||||
debug!("spectrum: audio flowing, streaming bars");
|
||||
}
|
||||
was_active = true;
|
||||
let bins = analyzer.analyze(&tap.snapshot());
|
||||
let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins }));
|
||||
} else if was_active {
|
||||
// Playback just went idle: drop the bars to the floor once.
|
||||
debug!("spectrum: audio idle, bars to zero");
|
||||
was_active = false;
|
||||
let _ = update_tx.send(Update::Spectrum(SpectrumFrame {
|
||||
bins: vec![0.0; spectrum::SPECTRUM_BINS],
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Forwards player engine events into the playback message loop.
|
||||
#[instrument(skip(rx, tx))]
|
||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||
for msg in rx.iter() {
|
||||
let command = match msg {
|
||||
PlayerMessage::EndOfStream => {
|
||||
debug!("player reported end of stream");
|
||||
PlaybackCommand::Next
|
||||
}
|
||||
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
||||
state: PlayState::Stopped,
|
||||
},
|
||||
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
||||
state: PlayState::Paused,
|
||||
},
|
||||
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
||||
state: PlayState::Playing,
|
||||
},
|
||||
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
||||
duration: duration.as_millis() as u32,
|
||||
position: elapsed.as_millis() as u32,
|
||||
},
|
||||
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
||||
duration: duration.as_millis() as u32,
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
||||
error!("failed to forward player message: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
warn!("player message channel closed");
|
||||
}
|
||||
|
||||
/// How a pending queue operation places its resolved chunks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResolveKind {
|
||||
/// First chunk replaces the whole queue, later chunks append.
|
||||
Replace,
|
||||
/// Every chunk appends at the end.
|
||||
Append,
|
||||
/// Chunks insert after the given position, each advancing the cursor so
|
||||
/// the resolved collection stays contiguous and in order. `Queue`
|
||||
/// (play-after-current) is an `InsertAfter` at the current position.
|
||||
InsertAfter(u32),
|
||||
}
|
||||
|
||||
/// The playback loop's bookkeeping for one in-flight resolve operation.
|
||||
///
|
||||
/// Created when a `Replace`/`Queue`/`Append`/`Insert` command arrives,
|
||||
/// dropped when its forwarder reports completion or a `Replace`/`Clear`
|
||||
/// cancels it. Chunk application happens exclusively on the playback loop,
|
||||
/// which keeps the loop the single writer of queue state.
|
||||
#[derive(Debug)]
|
||||
pub struct PendingResolve {
|
||||
kind: ResolveKind,
|
||||
/// Tracks applied so far; an op finishing at zero is worth a warning.
|
||||
applied: usize,
|
||||
/// Shared with the op's forwarder task: set on cancellation so the
|
||||
/// forwarder drops the chunk receiver, which stops the provider fetch.
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PendingResolve {
|
||||
pub fn new(kind: ResolveKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
applied: 0,
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cancellation flag to hand to this op's forwarder task.
|
||||
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.cancelled)
|
||||
}
|
||||
|
||||
/// Marks the op cancelled so its forwarder stops feeding chunks.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Total tracks applied by this op so far.
|
||||
pub fn applied(&self) -> usize {
|
||||
self.applied
|
||||
}
|
||||
|
||||
/// Applies one resolved chunk to the queue and advances this op's
|
||||
/// cursor. Returns the track that should start playing, if this chunk
|
||||
/// made one current (first chunk of a replace, or any chunk landing in
|
||||
/// an empty queue) — later chunks of the same op never restart playback.
|
||||
pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> {
|
||||
self.applied += tracks.len();
|
||||
match self.kind {
|
||||
ResolveKind::Replace => {
|
||||
// Only the first chunk replaces; the rest of this op
|
||||
// extends the fresh queue.
|
||||
self.kind = ResolveKind::Append;
|
||||
queue.replace_with_tracks(tracks)
|
||||
}
|
||||
ResolveKind::Append => queue.append_tracks(tracks),
|
||||
ResolveKind::InsertAfter(position) => {
|
||||
// Advance the cursor so this op's next chunk lands right
|
||||
// behind this one, keeping the collection contiguous.
|
||||
// `insert_tracks` clamps positions past the end.
|
||||
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
|
||||
queue.insert_tracks(position, tracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct QueueManager {
|
||||
|
|
@ -24,6 +325,10 @@ impl From<QueueManager> for Queue {
|
|||
.as_secs(),
|
||||
current_position: queue_manager.current_position() as u32,
|
||||
tracks: queue_manager.tracks,
|
||||
// The manager cannot know about in-flight resolves; the
|
||||
// playback loop's broadcast path sets this from its pending-op
|
||||
// map (see `Playback::broadcast_queue`).
|
||||
resolving: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +358,16 @@ impl QueueManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Number of tracks in the queue (playback uses it to bound skip
|
||||
/// loops: at most one full pass, even with repeat on).
|
||||
pub fn len(&self) -> usize {
|
||||
self.tracks.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tracks.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_last_track(&self) -> bool {
|
||||
!self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1
|
||||
}
|
||||
|
|
@ -310,6 +625,7 @@ mod tests {
|
|||
title: format!("track {id}"),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,6 +706,69 @@ mod tests {
|
|||
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]
|
||||
fn shuffle_insert_keeps_order_unique() {
|
||||
let mut q = queue_with(5);
|
||||
|
|
@ -402,3 +781,216 @@ mod tests {
|
|||
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
||||
}
|
||||
}
|
||||
/// A command for the provider orchestrator, tagged with the tracing span that
|
||||
/// was current when it was sent so the handler can attribute its events to
|
||||
/// the originating request.
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderMessage {
|
||||
pub span: Span,
|
||||
pub command: ProviderCommand,
|
||||
}
|
||||
|
||||
impl ProviderMessage {
|
||||
pub fn new(command: ProviderCommand) -> Self {
|
||||
Self {
|
||||
span: Span::current(),
|
||||
command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderCommand {
|
||||
GetLibraryNode {
|
||||
path: String,
|
||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||
},
|
||||
GetTrackUrls {
|
||||
path: String,
|
||||
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
||||
},
|
||||
/// Resolves a path into playable tracks: a track path yields that single
|
||||
/// track, a node path yields all tracks reachable below it. Streamed:
|
||||
/// zero or more in-order chunks arrive on `chunk_tx`, the sender is
|
||||
/// dropped when resolution finishes, and dropping the receiver cancels
|
||||
/// it (see `ProviderClient::resolve_tracks_into`). The orchestrator
|
||||
/// handles this command on a spawned task so its loop stays free for
|
||||
/// other commands (notably `GetTrackUrls` for the first chunk's track).
|
||||
ResolveTracks {
|
||||
path: String,
|
||||
chunk_tx: flume::Sender<Vec<Track>>,
|
||||
},
|
||||
/// Creates a child under a creatable node (see
|
||||
/// `ProviderClient::create_lib_node`); replies with the created node.
|
||||
CreateLibraryNode {
|
||||
parent_path: String,
|
||||
title: String,
|
||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||
},
|
||||
/// Renames an editable node (see `ProviderClient::rename_lib_node`);
|
||||
/// replies with the renamed node at its new path.
|
||||
RenameLibraryNode {
|
||||
path: String,
|
||||
new_title: String,
|
||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||
},
|
||||
/// Deletes a deletable node (see `ProviderClient::delete_lib_node`);
|
||||
/// replies with the refreshed parent node.
|
||||
DeleteLibraryNode {
|
||||
path: String,
|
||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||
},
|
||||
/// Captures the queueable subtree at `path` as the bookmark `name`
|
||||
/// (see `architecture/bookmarks.md` D1–D3) — or, with `download`, as
|
||||
/// the download capture `name` under `/captures`, fetching every
|
||||
/// track's audio (see `architecture/captures.md` and
|
||||
/// `architecture/incremental-captures.md`). Handled on a spawned
|
||||
/// task — a large walk or download must not block the orchestrator
|
||||
/// loop. `result_tx` answers once the capture is *accepted*
|
||||
/// (validation only); the walk then streams `CaptureProgress` events
|
||||
/// on `progress_tx`, ending in exactly one `finished` event (with
|
||||
/// `error` set on failure). A rejected capture answers with the error
|
||||
/// and sends no progress events.
|
||||
CaptureLibraryNode {
|
||||
path: String,
|
||||
name: String,
|
||||
download: bool,
|
||||
progress_tx: flume::Sender<crabidy_core::proto::crabidy::CaptureProgress>,
|
||||
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProviderCommand {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::GetLibraryNode { .. } => "get_library_node",
|
||||
Self::GetTrackUrls { .. } => "get_track_urls",
|
||||
Self::ResolveTracks { .. } => "resolve_tracks",
|
||||
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||||
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||||
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
||||
#[derive(Debug)]
|
||||
pub struct PlaybackMessage {
|
||||
pub span: Span,
|
||||
pub command: PlaybackCommand,
|
||||
}
|
||||
|
||||
impl PlaybackMessage {
|
||||
pub fn new(command: PlaybackCommand) -> Self {
|
||||
Self {
|
||||
span: Span::current(),
|
||||
command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PlaybackCommand {
|
||||
Init {
|
||||
result_tx: flume::Sender<InitResponse>,
|
||||
},
|
||||
Replace {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Queue {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Append {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Remove {
|
||||
positions: Vec<u32>,
|
||||
},
|
||||
Insert {
|
||||
position: u32,
|
||||
paths: Vec<String>,
|
||||
},
|
||||
/// Internal: a resolved chunk of tracks for the pending queue operation
|
||||
/// `op_id`, sent by that operation's forwarder task. Chunks for an
|
||||
/// unknown (finished or cancelled) op are dropped silently.
|
||||
ApplyResolvedChunk {
|
||||
op_id: u64,
|
||||
tracks: Vec<Track>,
|
||||
},
|
||||
/// Internal: the forwarder task for `op_id` has seen the provider drop
|
||||
/// its chunk sender — the operation is complete and the `resolving`
|
||||
/// flag clears once no pending operations remain.
|
||||
ResolveFinished {
|
||||
op_id: u64,
|
||||
},
|
||||
Clear {
|
||||
exclude_current: bool,
|
||||
},
|
||||
SetCurrent {
|
||||
position: u32,
|
||||
},
|
||||
/// Saves the current queue under a name (see
|
||||
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||||
/// snapshot is consistent; the disk write happens on a spawned task and
|
||||
/// reports through `result_tx`.
|
||||
SaveQueue {
|
||||
name: String,
|
||||
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>,
|
||||
},
|
||||
ToggleShuffle,
|
||||
ToggleRepeat,
|
||||
TogglePlay,
|
||||
Stop,
|
||||
ChangeVolume {
|
||||
delta: f32,
|
||||
},
|
||||
ToggleMute,
|
||||
Next,
|
||||
Prev,
|
||||
RestartTrack,
|
||||
StateChanged {
|
||||
state: PlayState,
|
||||
},
|
||||
VolumeChanged {
|
||||
volume: f32,
|
||||
},
|
||||
MuteChanged {
|
||||
muted: bool,
|
||||
},
|
||||
PositionChanged {
|
||||
duration: u32,
|
||||
position: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlaybackCommand {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Init { .. } => "init",
|
||||
Self::Replace { .. } => "replace",
|
||||
Self::Queue { .. } => "queue",
|
||||
Self::Append { .. } => "append",
|
||||
Self::Remove { .. } => "remove",
|
||||
Self::Insert { .. } => "insert",
|
||||
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
||||
Self::ResolveFinished { .. } => "resolve_finished",
|
||||
Self::Clear { .. } => "clear",
|
||||
Self::SetCurrent { .. } => "set_current",
|
||||
Self::SaveQueue { .. } => "save_queue",
|
||||
Self::ToggleShuffle => "toggle_shuffle",
|
||||
Self::ToggleRepeat => "toggle_repeat",
|
||||
Self::TogglePlay => "toggle_play",
|
||||
Self::Stop => "stop",
|
||||
Self::ChangeVolume { .. } => "change_volume",
|
||||
Self::ToggleMute => "toggle_mute",
|
||||
Self::Next => "next",
|
||||
Self::Prev => "prev",
|
||||
Self::RestartTrack => "restart_track",
|
||||
Self::StateChanged { .. } => "state_changed",
|
||||
Self::VolumeChanged { .. } => "volume_changed",
|
||||
Self::MuteChanged { .. } => "mute_changed",
|
||||
Self::PositionChanged { .. } => "position_changed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,47 @@
|
|||
use audio_player::PlayerMessage;
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
|
||||
};
|
||||
use crabidy_core::{ProviderClient, ProviderError};
|
||||
use tracing::{debug, error, info, instrument, warn, Span};
|
||||
//! The standalone server binary: stderr tracing plus
|
||||
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack
|
||||
//! lives in the library so the bundled `cbd` binary can host it too
|
||||
//! (architecture/cbd-bundle.md D1).
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
|
||||
mod playback;
|
||||
use playback::Playback;
|
||||
mod provider;
|
||||
use provider::ProviderOrchestrator;
|
||||
mod rpc;
|
||||
use rpc::RpcService;
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
use tonic::{transport::Server, Result};
|
||||
|
||||
const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||||
#[derive(clap::Subcommand)]
|
||||
enum Command {
|
||||
/// Hash a password for the `[auth]` section of crabidy-server.toml
|
||||
/// (architecture/roles-auth.md). Reads the password as one line
|
||||
/// from stdin and prints the PHC string — nothing else, so output
|
||||
/// can be piped. The password itself is never printed or logged.
|
||||
HashPassword,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cli = Cli::parse();
|
||||
if let Some(Command::HashPassword) = cli.command {
|
||||
return hash_password();
|
||||
}
|
||||
let _log_guard = init_tracing();
|
||||
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
|
||||
error!("failed to init provider orchestrator: {err}");
|
||||
err
|
||||
})?;
|
||||
|
||||
let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
|
||||
|
||||
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?;
|
||||
|
||||
/// Reads one line from stdin and prints its argon2 PHC hash.
|
||||
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut password = String::new();
|
||||
std::io::stdin().read_line(&mut password)?;
|
||||
let password = password.trim_end_matches(['\r', '\n']);
|
||||
if password.is_empty() {
|
||||
return Err("empty password".into());
|
||||
}
|
||||
println!("{}", crabidy_server::auth::hash_password(password)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +55,7 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
|||
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new(
|
||||
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
|
||||
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug",
|
||||
)
|
||||
});
|
||||
|
||||
|
|
@ -86,181 +74,3 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
|||
|
||||
guard
|
||||
}
|
||||
|
||||
/// Forwards player engine events into the playback message loop.
|
||||
#[instrument(skip(rx, tx))]
|
||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||
for msg in rx.iter() {
|
||||
let command = match msg {
|
||||
PlayerMessage::EndOfStream => {
|
||||
debug!("player reported end of stream");
|
||||
PlaybackCommand::Next
|
||||
}
|
||||
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
||||
state: PlayState::Stopped,
|
||||
},
|
||||
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
||||
state: PlayState::Paused,
|
||||
},
|
||||
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
||||
state: PlayState::Playing,
|
||||
},
|
||||
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
||||
duration: duration.as_millis() as u32,
|
||||
position: elapsed.as_millis() as u32,
|
||||
},
|
||||
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
||||
duration: duration.as_millis() as u32,
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
||||
error!("failed to forward player message: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
warn!("player message channel closed");
|
||||
}
|
||||
|
||||
/// A command for the provider orchestrator, tagged with the tracing span that
|
||||
/// was current when it was sent so the handler can attribute its events to
|
||||
/// the originating request.
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderMessage {
|
||||
pub span: Span,
|
||||
pub command: ProviderCommand,
|
||||
}
|
||||
|
||||
impl ProviderMessage {
|
||||
pub fn new(command: ProviderCommand) -> Self {
|
||||
Self {
|
||||
span: Span::current(),
|
||||
command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderCommand {
|
||||
GetLibraryNode {
|
||||
path: String,
|
||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||
},
|
||||
GetTrackUrls {
|
||||
path: String,
|
||||
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
||||
},
|
||||
/// Resolves a path into playable tracks: a track path yields that single
|
||||
/// track, a node path yields all tracks reachable below it.
|
||||
ResolveTracks {
|
||||
path: String,
|
||||
result_tx: flume::Sender<Vec<Track>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProviderCommand {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::GetLibraryNode { .. } => "get_library_node",
|
||||
Self::GetTrackUrls { .. } => "get_track_urls",
|
||||
Self::ResolveTracks { .. } => "resolve_tracks",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
||||
#[derive(Debug)]
|
||||
pub struct PlaybackMessage {
|
||||
pub span: Span,
|
||||
pub command: PlaybackCommand,
|
||||
}
|
||||
|
||||
impl PlaybackMessage {
|
||||
pub fn new(command: PlaybackCommand) -> Self {
|
||||
Self {
|
||||
span: Span::current(),
|
||||
command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PlaybackCommand {
|
||||
Init {
|
||||
result_tx: flume::Sender<InitResponse>,
|
||||
},
|
||||
Replace {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Queue {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Append {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Remove {
|
||||
positions: Vec<u32>,
|
||||
},
|
||||
Insert {
|
||||
position: u32,
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Clear {
|
||||
exclude_current: bool,
|
||||
},
|
||||
SetCurrent {
|
||||
position: u32,
|
||||
},
|
||||
ToggleShuffle,
|
||||
ToggleRepeat,
|
||||
TogglePlay,
|
||||
Stop,
|
||||
ChangeVolume {
|
||||
delta: f32,
|
||||
},
|
||||
ToggleMute,
|
||||
Next,
|
||||
Prev,
|
||||
RestartTrack,
|
||||
StateChanged {
|
||||
state: PlayState,
|
||||
},
|
||||
VolumeChanged {
|
||||
volume: f32,
|
||||
},
|
||||
MuteChanged {
|
||||
muted: bool,
|
||||
},
|
||||
PositionChanged {
|
||||
duration: u32,
|
||||
position: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlaybackCommand {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Init { .. } => "init",
|
||||
Self::Replace { .. } => "replace",
|
||||
Self::Queue { .. } => "queue",
|
||||
Self::Append { .. } => "append",
|
||||
Self::Remove { .. } => "remove",
|
||||
Self::Insert { .. } => "insert",
|
||||
Self::Clear { .. } => "clear",
|
||||
Self::SetCurrent { .. } => "set_current",
|
||||
Self::ToggleShuffle => "toggle_shuffle",
|
||||
Self::ToggleRepeat => "toggle_repeat",
|
||||
Self::TogglePlay => "toggle_play",
|
||||
Self::Stop => "stop",
|
||||
Self::ChangeVolume { .. } => "change_volume",
|
||||
Self::ToggleMute => "toggle_mute",
|
||||
Self::Next => "next",
|
||||
Self::Prev => "prev",
|
||||
Self::RestartTrack => "restart_track",
|
||||
Self::StateChanged { .. } => "state_changed",
|
||||
Self::VolumeChanged { .. } => "volume_changed",
|
||||
Self::MuteChanged { .. } => "mute_changed",
|
||||
Self::PositionChanged { .. } => "position_changed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
use crate::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
|
||||
use crate::{PendingResolve, QueueManager, ResolveKind};
|
||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||
use audio_player::Player;
|
||||
use crabidy_core::proto::crabidy::QueueModifiers;
|
||||
use crabidy_core::proto::crabidy::{
|
||||
get_update_stream_response::Update as StreamUpdate, InitResponse, PlayState, QueueTrack, Track,
|
||||
TrackPosition,
|
||||
get_update_stream_response::Update as StreamUpdate, InitResponse, PlayState,
|
||||
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
||||
};
|
||||
use crabidy_core::ProviderError;
|
||||
use crabidy_server::QueueManager;
|
||||
use std::sync::Mutex;
|
||||
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument};
|
||||
|
||||
pub struct Playback {
|
||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||
|
|
@ -17,6 +20,17 @@ pub struct Playback {
|
|||
playback_rx: flume::Receiver<PlaybackMessage>,
|
||||
queue: Mutex<QueueManager>,
|
||||
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,
|
||||
}
|
||||
|
||||
|
|
@ -24,10 +38,12 @@ impl Playback {
|
|||
pub fn new(
|
||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||
provider_tx: flume::Sender<ProviderMessage>,
|
||||
store: Option<Arc<QueueStore>>,
|
||||
) -> Self {
|
||||
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||
let queue = Mutex::new(QueueManager::new());
|
||||
let state = Mutex::new(PlayState::Stopped);
|
||||
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
||||
let player = Player::default();
|
||||
Self {
|
||||
update_tx,
|
||||
|
|
@ -36,11 +52,52 @@ impl Playback {
|
|||
playback_rx,
|
||||
queue,
|
||||
state,
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
next_op_id: AtomicU64::new(0),
|
||||
store,
|
||||
persist_tx,
|
||||
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) {
|
||||
if let Some(store) = &self.store {
|
||||
queue_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
||||
// Attribute all handler events to a span that is a child of
|
||||
|
|
@ -78,7 +135,9 @@ impl Playback {
|
|||
*play_state
|
||||
};
|
||||
InitResponse {
|
||||
queue: Some(queue.clone().into()),
|
||||
// Snapshot with `resolving`: a client connecting
|
||||
// mid-resolve must show the indicator right away.
|
||||
queue: Some(self.queue_snapshot(&queue)),
|
||||
queue_track: Some(queue_track),
|
||||
play_state: play_state as i32,
|
||||
volume: 0.0,
|
||||
|
|
@ -97,48 +156,34 @@ impl Playback {
|
|||
}
|
||||
|
||||
PlaybackCommand::Replace { paths } => {
|
||||
let all_tracks = self.resolve_tracks(paths).await;
|
||||
debug!(count = all_tracks.len(), "replacing queue");
|
||||
let current = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
queue.replace_with_tracks(&all_tracks);
|
||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||
queue.current_track()
|
||||
};
|
||||
self.play(current).await;
|
||||
// A replace obsoletes whatever earlier ops are still
|
||||
// resolving; their late chunks must not land in the new
|
||||
// queue.
|
||||
self.cancel_pending_resolves();
|
||||
self.start_resolve(ResolveKind::Replace, paths);
|
||||
}
|
||||
|
||||
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 {
|
||||
let position = {
|
||||
let Ok(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
|
||||
queue.current_position() as u32
|
||||
};
|
||||
self.play_if_some(track).await;
|
||||
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||
}
|
||||
|
||||
PlaybackCommand::Append { paths } => {
|
||||
let all_tracks = self.resolve_tracks(paths).await;
|
||||
debug!(count = all_tracks.len(), "appending to queue");
|
||||
let track = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
let track = queue.append_tracks(&all_tracks);
|
||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||
track
|
||||
};
|
||||
self.play_if_some(track).await;
|
||||
self.start_resolve(ResolveKind::Append, paths);
|
||||
}
|
||||
|
||||
PlaybackCommand::ApplyResolvedChunk { op_id, tracks } => {
|
||||
self.apply_resolved_chunk(op_id, tracks).await;
|
||||
}
|
||||
|
||||
PlaybackCommand::ResolveFinished { op_id } => {
|
||||
self.finish_resolve(op_id);
|
||||
}
|
||||
|
||||
PlaybackCommand::Remove { positions } => {
|
||||
|
|
@ -150,7 +195,7 @@ impl Playback {
|
|||
};
|
||||
let was_last = queue.is_last_track();
|
||||
let track = queue.remove_tracks(&positions);
|
||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||
self.broadcast_queue(&queue);
|
||||
(track, was_last)
|
||||
};
|
||||
let state = {
|
||||
|
|
@ -172,29 +217,21 @@ impl Playback {
|
|||
}
|
||||
|
||||
PlaybackCommand::Insert { 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;
|
||||
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||
}
|
||||
|
||||
PlaybackCommand::Clear { exclude_current } => {
|
||||
debug!(exclude_current, "clearing queue");
|
||||
// Chunks still resolving would repopulate the queue the
|
||||
// user just emptied.
|
||||
self.cancel_pending_resolves();
|
||||
let should_stop = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
let should_stop = queue.clear(exclude_current);
|
||||
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||
self.broadcast_queue(&queue);
|
||||
should_stop
|
||||
};
|
||||
if should_stop {
|
||||
|
|
@ -215,6 +252,39 @@ impl Playback {
|
|||
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 => {
|
||||
let (shuffle, repeat) = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
|
|
@ -226,6 +296,7 @@ impl Playback {
|
|||
} else {
|
||||
queue.shuffle_on()
|
||||
}
|
||||
self.send_persist_snapshot(&queue);
|
||||
(queue.shuffle, queue.repeat)
|
||||
};
|
||||
debug!(shuffle, "toggled shuffle");
|
||||
|
|
@ -239,6 +310,7 @@ impl Playback {
|
|||
return;
|
||||
};
|
||||
queue.repeat = !queue.repeat;
|
||||
self.send_persist_snapshot(&queue);
|
||||
(queue.shuffle, queue.repeat)
|
||||
};
|
||||
debug!(repeat, "toggled repeat");
|
||||
|
|
@ -280,10 +352,13 @@ impl Playback {
|
|||
};
|
||||
}
|
||||
|
||||
PlaybackCommand::ToggleMute => {
|
||||
// FIXME: implement mute in the player engine
|
||||
debug!("toggle mute requested (not implemented)");
|
||||
}
|
||||
PlaybackCommand::ToggleMute => match self.player.toggle_mute().await {
|
||||
Ok(muted) => {
|
||||
debug!(muted, "toggled mute");
|
||||
self.broadcast(StreamUpdate::Mute(muted));
|
||||
}
|
||||
Err(err) => warn!("toggle_mute failed: {err:?}"),
|
||||
},
|
||||
|
||||
PlaybackCommand::Next => {
|
||||
let track = {
|
||||
|
|
@ -360,32 +435,192 @@ impl Playback {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolves a mixed list of track and node paths into tracks by asking
|
||||
/// the provider orchestrator.
|
||||
async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
|
||||
let mut all_tracks = Vec::new();
|
||||
for path in paths {
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
||||
path: path.clone(),
|
||||
result_tx,
|
||||
});
|
||||
if let Err(err) = self.provider_tx.send_async(message).await {
|
||||
error!("provider channel closed: {err}");
|
||||
return all_tracks;
|
||||
}
|
||||
match result_rx.recv_async().await {
|
||||
Ok(tracks) => {
|
||||
if tracks.is_empty() {
|
||||
/// Registers a pending resolve operation and spawns its forwarder task.
|
||||
///
|
||||
/// The forwarder resolves `paths` one after the other (preserving the
|
||||
/// request's path order): for each path it sends
|
||||
/// `ProviderCommand::ResolveTracks` with a fresh bounded chunk channel
|
||||
/// and forwards every chunk to the playback loop as
|
||||
/// `PlaybackCommand::ApplyResolvedChunk`; after the last path it sends
|
||||
/// `ResolveFinished`. When the op's cancellation flag is set, the
|
||||
/// forwarder drops the chunk receiver instead — the provider's next
|
||||
/// send fails and the fetch stops. Queue state is never touched here:
|
||||
/// mutations happen only when the loop processes the forwarded
|
||||
/// commands. An immediate `Queue` broadcast (unchanged tracks,
|
||||
/// `resolving = true`) gives clients instant feedback.
|
||||
fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) {
|
||||
let op = PendingResolve::new(kind);
|
||||
let cancelled = op.cancel_flag();
|
||||
let op_id = self.next_op_id.fetch_add(1, Ordering::Relaxed);
|
||||
{
|
||||
let Ok(mut pending) = self.pending.lock() else {
|
||||
error!("pending ops lock poisoned");
|
||||
return;
|
||||
};
|
||||
pending.insert(op_id, op);
|
||||
}
|
||||
debug!(op_id, ?paths, "starting queue resolve");
|
||||
{
|
||||
let Ok(queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
// Instant feedback: clients see resolving = true before the
|
||||
// first chunk exists.
|
||||
self.broadcast_queue(&queue);
|
||||
}
|
||||
let provider_tx = self.provider_tx.clone();
|
||||
let playback_tx = self.playback_tx.clone();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
for path in &paths {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(4);
|
||||
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
||||
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");
|
||||
}
|
||||
all_tracks.extend(tracks);
|
||||
}
|
||||
Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"),
|
||||
// Always reported — also for cancelled or empty ops — so
|
||||
// the pending map can never leak a stuck resolving flag.
|
||||
let finished = PlaybackCommand::ResolveFinished { op_id };
|
||||
let _ = playback_tx.send_async(PlaybackMessage::new(finished)).await;
|
||||
}
|
||||
.in_current_span(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies one chunk to the queue for pending op `op_id`, broadcasts
|
||||
/// the grown queue, and starts playback when the chunk made a track
|
||||
/// current. Chunks for an unknown op id (finished or cancelled) are
|
||||
/// dropped silently.
|
||||
async fn apply_resolved_chunk(&self, op_id: u64, tracks: Vec<Track>) {
|
||||
let track = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
let track = {
|
||||
let Ok(mut pending) = self.pending.lock() else {
|
||||
error!("pending ops lock poisoned");
|
||||
return;
|
||||
};
|
||||
let Some(op) = pending.get_mut(&op_id) else {
|
||||
trace!(op_id, "dropping chunk for a finished or cancelled op");
|
||||
return;
|
||||
};
|
||||
op.apply_chunk(&mut queue, &tracks)
|
||||
};
|
||||
self.broadcast_queue(&queue);
|
||||
track
|
||||
};
|
||||
self.play_if_some(track).await;
|
||||
}
|
||||
|
||||
/// Removes the finished op and broadcasts the final `Queue` snapshot
|
||||
/// (clearing `resolving` once no ops remain). An op already removed by
|
||||
/// cancellation needs no broadcast — the cancelling command mutates
|
||||
/// the queue and broadcasts itself.
|
||||
fn finish_resolve(&self, op_id: u64) {
|
||||
let removed = {
|
||||
let Ok(mut pending) = self.pending.lock() else {
|
||||
error!("pending ops lock poisoned");
|
||||
return;
|
||||
};
|
||||
pending.remove(&op_id)
|
||||
};
|
||||
let Some(op) = removed else {
|
||||
trace!(op_id, "resolve finished for a cancelled op");
|
||||
return;
|
||||
};
|
||||
debug!(op_id, tracks = op.applied(), "queue resolve finished");
|
||||
let Ok(queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
self.broadcast_queue(&queue);
|
||||
}
|
||||
|
||||
/// Cancels every in-flight resolve op (used by `Replace` and `Clear`).
|
||||
/// The forwarders see the flag, drop their chunk receivers (stopping
|
||||
/// the fetches) and still report `ResolveFinished`, which is dropped as
|
||||
/// unknown here.
|
||||
fn cancel_pending_resolves(&self) {
|
||||
let Ok(mut pending) = self.pending.lock() else {
|
||||
error!("pending ops lock poisoned");
|
||||
return;
|
||||
};
|
||||
for (op_id, op) in pending.drain() {
|
||||
debug!(op_id, "cancelling in-flight resolve");
|
||||
op.cancel();
|
||||
}
|
||||
trace!(count = all_tracks.len(), "resolved tracks");
|
||||
all_tracks
|
||||
}
|
||||
|
||||
/// A wire snapshot of the queue with the `resolving` flag set from the
|
||||
/// pending-op map. Callers must not hold the `pending` lock (`queue` is
|
||||
/// fine — the lock order is queue, then pending).
|
||||
fn queue_snapshot(&self, queue: &QueueManager) -> ProtoQueue {
|
||||
let resolving = self
|
||||
.pending
|
||||
.lock()
|
||||
.map(|pending| !pending.is_empty())
|
||||
.unwrap_or(false);
|
||||
let mut snapshot: ProtoQueue = queue.clone().into();
|
||||
snapshot.resolving = resolving;
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Broadcasts the current queue snapshot. All queue broadcasts go
|
||||
/// through here so the `resolving` flag can never be forgotten — and
|
||||
/// every queue-content change reaches the persister the same way.
|
||||
fn broadcast_queue(&self, queue: &QueueManager) {
|
||||
self.send_persist_snapshot(queue);
|
||||
self.broadcast(StreamUpdate::Queue(self.queue_snapshot(queue)));
|
||||
}
|
||||
|
||||
/// Hands the queue's persistable state to the persister task; a no-op
|
||||
/// when persistence is disabled. Latest snapshot wins, so calling this
|
||||
/// on every mutation is free of backpressure (the persister skips
|
||||
/// writes for unchanged snapshots).
|
||||
fn send_persist_snapshot(&self, queue: &QueueManager) {
|
||||
if self.store.is_none() {
|
||||
return;
|
||||
}
|
||||
let proto: ProtoQueue = queue.clone().into();
|
||||
self.persist_tx.send_replace(Some(QueueSnapshot {
|
||||
tracks: proto.tracks,
|
||||
current_position: proto.current_position,
|
||||
repeat: queue.repeat,
|
||||
shuffle: queue.shuffle,
|
||||
}));
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
|
|
@ -428,43 +663,76 @@ impl Playback {
|
|||
}
|
||||
}
|
||||
|
||||
/// Starts playback of the given track. When fetching stream URLs fails
|
||||
/// the failing track is skipped and playback continues with the next
|
||||
/// track in the queue.
|
||||
/// Finds the stream URLs of the first playable track, starting at
|
||||
/// `track` and advancing the queue past unplayable ones. Tracks marked
|
||||
/// `is_skipped` (captures recorded their source as uncapturable) are
|
||||
/// skipped without a provider round trip; tracks whose stream URLs
|
||||
/// fail to resolve are skipped with a warning. Bounded by the queue
|
||||
/// length at entry — one full pass at most — so an all-skipped queue
|
||||
/// with repeat on returns `None` instead of spinning
|
||||
/// (architecture/incremental-captures.md D3).
|
||||
async fn next_playable_urls(&self, mut track: Track) -> Option<Vec<String>> {
|
||||
let mut attempts_left = {
|
||||
let Ok(queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return None;
|
||||
};
|
||||
queue.len()
|
||||
};
|
||||
loop {
|
||||
let path = track.path.as_str();
|
||||
if track.is_skipped {
|
||||
debug!(path, "track is marked skipped, skipping");
|
||||
} else {
|
||||
match self.get_urls_for_track(path).await {
|
||||
Ok(urls) if !urls.is_empty() => return Some(urls),
|
||||
Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
|
||||
Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
|
||||
}
|
||||
}
|
||||
attempts_left = attempts_left.saturating_sub(1);
|
||||
if attempts_left == 0 {
|
||||
warn!("no playable track in the queue after a full pass");
|
||||
return None;
|
||||
}
|
||||
let next = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return None;
|
||||
};
|
||||
queue.next_track()
|
||||
};
|
||||
match next {
|
||||
Some(next_track) => track = next_track,
|
||||
None => {
|
||||
debug!("reached the end of the queue without a playable track");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts playback of the given track, skipping past unplayable ones
|
||||
/// (see [`Self::next_playable_urls`]); stops the player when nothing
|
||||
/// in the queue is playable.
|
||||
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
||||
async fn play(&self, track: Option<Track>) {
|
||||
let Some(track) = track else {
|
||||
debug!("nothing to play");
|
||||
return;
|
||||
};
|
||||
let mut path = track.path.clone();
|
||||
let urls = loop {
|
||||
match self.get_urls_for_track(&path).await {
|
||||
Ok(urls) if !urls.is_empty() => break urls,
|
||||
Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
|
||||
Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
|
||||
}
|
||||
let next = {
|
||||
let Ok(mut queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
return;
|
||||
};
|
||||
queue.next_track()
|
||||
};
|
||||
match next {
|
||||
Some(next_track) => path = next_track.path.clone(),
|
||||
None => {
|
||||
error!("no playable track left in queue, stopping");
|
||||
self.stop_player().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(urls) = self.next_playable_urls(track).await else {
|
||||
self.stop_player().await;
|
||||
return;
|
||||
};
|
||||
{
|
||||
let Ok(queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
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 {
|
||||
queue_position: queue.current_position() as u32,
|
||||
track: queue.current_track(),
|
||||
|
|
@ -476,3 +744,177 @@ impl Playback {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn track(i: usize) -> Track {
|
||||
Track {
|
||||
path: format!("/tidal/playlists/p/{i}"),
|
||||
artist: "artist".to_string(),
|
||||
title: format!("track {i}"),
|
||||
duration: None,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn store_in(dir: &TempDir) -> Arc<QueueStore> {
|
||||
Arc::new(
|
||||
QueueStore::open(dir.path().join("queues"))
|
||||
.await
|
||||
.expect("open store"),
|
||||
)
|
||||
}
|
||||
|
||||
fn playback_with(store: Option<Arc<QueueStore>>) -> Playback {
|
||||
let (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
let (provider_tx, _provider_rx) = flume::bounded(16);
|
||||
Playback::new(update_tx, provider_tx, store)
|
||||
}
|
||||
|
||||
fn fill_queue(playback: &Playback, n: usize) {
|
||||
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||
let mut queue = playback.queue.lock().expect("queue lock");
|
||||
let _ = queue.replace_with_tracks(&tracks);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_fills_the_queue_without_starting_playback() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let store = store_in(&dir).await;
|
||||
store
|
||||
.persist_current(&QueueSnapshot {
|
||||
tracks: (0..3).map(track).collect(),
|
||||
current_position: 1,
|
||||
repeat: true,
|
||||
shuffle: false,
|
||||
})
|
||||
.await
|
||||
.expect("persist");
|
||||
|
||||
let playback = playback_with(Some(store));
|
||||
playback.restore_current().await;
|
||||
|
||||
let queue = playback.queue.lock().expect("queue lock");
|
||||
let snapshot: ProtoQueue = queue.clone().into();
|
||||
assert_eq!(snapshot.tracks.len(), 3);
|
||||
assert_eq!(queue.current_position(), 1);
|
||||
assert!(queue.repeat);
|
||||
// A restarted server stays silent: restoring must not play.
|
||||
assert_eq!(
|
||||
*playback.state.lock().expect("state lock"),
|
||||
PlayState::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_survives_an_out_of_range_position() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let store = store_in(&dir).await;
|
||||
store
|
||||
.persist_current(&QueueSnapshot {
|
||||
tracks: vec![track(0)],
|
||||
current_position: 99, // hand-edited folder
|
||||
repeat: false,
|
||||
shuffle: false,
|
||||
})
|
||||
.await
|
||||
.expect("persist");
|
||||
let playback = playback_with(Some(store));
|
||||
playback.restore_current().await;
|
||||
let queue = playback.queue.lock().expect("queue lock");
|
||||
assert_eq!(queue.current_position(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_queue_command_snapshots_the_live_queue() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let store = store_in(&dir).await;
|
||||
let playback = playback_with(Some(Arc::clone(&store)));
|
||||
fill_queue(&playback, 2);
|
||||
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
playback
|
||||
.handle_command(PlaybackCommand::SaveQueue {
|
||||
name: "road trip".to_string(),
|
||||
result_tx,
|
||||
})
|
||||
.await;
|
||||
result_rx
|
||||
.recv_async()
|
||||
.await
|
||||
.expect("reply")
|
||||
.expect("save succeeds");
|
||||
|
||||
let entries = std::fs::read_dir(store.dir().join("road trip"))
|
||||
.expect("saved queue folder")
|
||||
.filter(|e| {
|
||||
!e.as_ref()
|
||||
.expect("entry")
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with('.')
|
||||
})
|
||||
.count();
|
||||
assert_eq!(entries, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_queue_rejects_an_empty_queue() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let playback = playback_with(Some(store_in(&dir).await));
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
playback
|
||||
.handle_command(PlaybackCommand::SaveQueue {
|
||||
name: "empty".to_string(),
|
||||
result_tx,
|
||||
})
|
||||
.await;
|
||||
let result = result_rx.recv_async().await.expect("reply");
|
||||
assert!(matches!(result, Err(SaveQueueError::EmptyQueue)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skipped_tracks_are_skipped_with_a_bounded_pass() {
|
||||
// All tracks are marked skipped and repeat is on: `next_track`
|
||||
// cycles forever, so only the one-full-pass bound ends the loop
|
||||
// with `None`. The marked tracks are skipped without any provider
|
||||
// round trip (the provider channel is closed — a call would fail,
|
||||
// not hang).
|
||||
let playback = playback_with(None);
|
||||
let tracks: Vec<Track> = (0..3)
|
||||
.map(|i| Track {
|
||||
is_skipped: true,
|
||||
..track(i)
|
||||
})
|
||||
.collect();
|
||||
let first = {
|
||||
let mut queue = playback.queue.lock().expect("queue lock");
|
||||
let first = queue.replace_with_tracks(&tracks);
|
||||
queue.repeat = true;
|
||||
first
|
||||
};
|
||||
let urls = playback
|
||||
.next_playable_urls(first.expect("first track"))
|
||||
.await;
|
||||
assert!(urls.is_none(), "an all-skipped queue has nothing playable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_mutations_reach_the_persist_channel() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let playback = playback_with(Some(store_in(&dir).await));
|
||||
fill_queue(&playback, 2);
|
||||
let rx = playback.persist_tx.subscribe();
|
||||
|
||||
playback
|
||||
.handle_command(PlaybackCommand::Remove { positions: vec![1] })
|
||||
.await;
|
||||
|
||||
let snapshot = rx.borrow().clone().expect("snapshot sent");
|
||||
assert_eq!(snapshot.tracks.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
use crate::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
||||
use crate::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
|
||||
use crate::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
||||
use crate::{ProviderCommand, ProviderMessage};
|
||||
use async_trait::async_trait;
|
||||
use crabidy_core::{
|
||||
|
|
@ -12,21 +15,121 @@ pub struct ProviderOrchestrator {
|
|||
pub provider_tx: flume::Sender<ProviderMessage>,
|
||||
provider_rx: flume::Receiver<ProviderMessage>,
|
||||
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 {
|
||||
/// 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) {
|
||||
tokio::spawn(async move {
|
||||
while let Ok(ProviderMessage { span, command }) = self.provider_rx.recv_async().await {
|
||||
// Behind an Arc so long-running resolves can be spawned onto
|
||||
// their own tasks while the loop keeps serving commands.
|
||||
let this = Arc::new(self);
|
||||
while let Ok(ProviderMessage { span, command }) = this.provider_rx.recv_async().await {
|
||||
let handler_span =
|
||||
debug_span!(parent: &span, "provider_command", command = command.name());
|
||||
self.handle_command(command).instrument(handler_span).await;
|
||||
Arc::clone(&this)
|
||||
.handle_command(command)
|
||||
.instrument(handler_span)
|
||||
.await;
|
||||
}
|
||||
warn!("provider message channel closed, loop exiting");
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_command(&self, command: ProviderCommand) {
|
||||
async fn handle_command(self: Arc<Self>, command: ProviderCommand) {
|
||||
match command {
|
||||
ProviderCommand::GetLibraryNode { path, result_tx } => {
|
||||
let result = self.get_lib_node(&path).await;
|
||||
|
|
@ -40,46 +143,108 @@ impl ProviderOrchestrator {
|
|||
error!("failed to send get_track_urls result: {err}");
|
||||
}
|
||||
}
|
||||
ProviderCommand::ResolveTracks { path, result_tx } => {
|
||||
let result = self.resolve_tracks(&path).await;
|
||||
ProviderCommand::ResolveTracks { path, chunk_tx } => {
|
||||
// Spawned: a large resolve must not block this loop, or the
|
||||
// playback side deadlocks waiting for `GetTrackUrls` while
|
||||
// chunks back up. Dropping `chunk_tx` at the end of the
|
||||
// task is the completion signal; there is no reply channel.
|
||||
let this = Arc::clone(&self);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(err) = this.resolve_tracks_into(&path, chunk_tx).await {
|
||||
warn!(path, "resolve produced no tracks: {err}");
|
||||
}
|
||||
}
|
||||
.in_current_span(),
|
||||
);
|
||||
}
|
||||
ProviderCommand::CreateLibraryNode {
|
||||
parent_path,
|
||||
title,
|
||||
result_tx,
|
||||
} => {
|
||||
let result = self.create_lib_node(&parent_path, &title).await;
|
||||
if let Err(err) = result_tx.send_async(result).await {
|
||||
error!("failed to send resolve_tracks result: {err}");
|
||||
error!("failed to send create_library_node result: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
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}");
|
||||
}
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
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}");
|
||||
}
|
||||
};
|
||||
if node.is_queable {
|
||||
tracks.extend(node.tracks);
|
||||
nodes_to_go.extend(node.children.into_iter().map(|c| c.path))
|
||||
}
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
debug!(count = tracks.len(), "resolved path into tracks");
|
||||
tracks
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,11 +276,132 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
||||
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);
|
||||
Ok(Self {
|
||||
provider_rx,
|
||||
provider_tx,
|
||||
tidal_client,
|
||||
fs_client,
|
||||
queues_client,
|
||||
bookmarks_client,
|
||||
bookmark_store,
|
||||
captures_client,
|
||||
capture_store,
|
||||
youtube_client,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +414,36 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if path == "/tidal" || path.starts_with("/tidal/") {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +452,30 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if track_path.starts_with("/tidal/") {
|
||||
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");
|
||||
Err(ProviderError::MalformedPath)
|
||||
}
|
||||
|
|
@ -145,6 +485,33 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if track_path.starts_with("/tidal/") {
|
||||
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");
|
||||
Err(ProviderError::MalformedPath)
|
||||
}
|
||||
|
|
@ -154,6 +521,37 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
let child =
|
||||
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +564,180 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||
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");
|
||||
Err(ProviderError::MalformedPath)
|
||||
}
|
||||
|
||||
/// Routes to the provider that owns the parent path. The synthetic root
|
||||
/// itself is not creatable.
|
||||
#[instrument(skip(self))]
|
||||
async fn create_lib_node(
|
||||
&self,
|
||||
parent_path: &str,
|
||||
title: &str,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
||||
return self.tidal_client.create_lib_node(parent_path, title).await;
|
||||
}
|
||||
if fs_owns(parent_path) {
|
||||
return self
|
||||
.fs_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if queues_owns(parent_path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(parent_path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(parent_path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if youtube_owns(parent_path) {
|
||||
return self
|
||||
.youtube_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
warn!(parent_path, "no provider supports creating nodes here");
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
|
||||
/// Routes to the provider that owns the path. The synthetic root's own
|
||||
/// children are fixed and never editable.
|
||||
#[instrument(skip(self))]
|
||||
async fn rename_lib_node(
|
||||
&self,
|
||||
path: &str,
|
||||
new_title: &str,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||
return self.tidal_client.rename_lib_node(path, new_title).await;
|
||||
}
|
||||
if fs_owns(path) {
|
||||
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
if youtube_owns(path) {
|
||||
return self
|
||||
.youtube_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
warn!(path, "no provider supports renaming this node");
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
|
||||
/// Routes to the provider that owns the path. The synthetic root is not
|
||||
/// queueable, so only provider-owned paths can resolve.
|
||||
#[instrument(skip(self, chunk_tx))]
|
||||
async fn resolve_tracks_into(
|
||||
&self,
|
||||
path: &str,
|
||||
chunk_tx: flume::Sender<Vec<Track>>,
|
||||
) -> Result<(), ProviderError> {
|
||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||
return self.tidal_client.resolve_tracks_into(path, chunk_tx).await;
|
||||
}
|
||||
if fs_owns(path) {
|
||||
return self
|
||||
.fs_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if youtube_owns(path) {
|
||||
return self
|
||||
.youtube_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
warn!(path, "no provider owns this path");
|
||||
Err(ProviderError::MalformedPath)
|
||||
}
|
||||
|
||||
/// Routes to the provider that owns the path. The synthetic root's own
|
||||
/// children are fixed and never deletable.
|
||||
#[instrument(skip(self))]
|
||||
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||
return self.tidal_client.delete_lib_node(path).await;
|
||||
}
|
||||
if fs_owns(path) {
|
||||
return self.fs_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
return self.queues_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self.bookmarks_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self.captures_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if youtube_owns(path) {
|
||||
return self.youtube_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
warn!(path, "no provider supports deleting this node");
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,472 @@
|
|||
//! Persisted queues on disk (see `architecture/queue-persistence.md`).
|
||||
//!
|
||||
//! Every queue is a folder under the store directory
|
||||
//! (`<config>/crabidy/queues/`) holding one order-prefixed
|
||||
//! `*.cbd-track.toml` **link** file per entry, plus a hidden
|
||||
//! [`STATE_FILE_NAME`] sidecar. The automatically maintained queue lives in
|
||||
//! [`CURRENT_QUEUE_NAME`]; every other folder is a named save. The same
|
||||
//! directory is mounted read-only into the library as `/queues` by a second
|
||||
//! `fsdy` instance — this module is the only writer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crabidy_core::proto::crabidy::Track;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// The library mount point of the queues directory (second `fsdy`
|
||||
/// instance, see `architecture/queue-persistence.md` D1).
|
||||
pub const QUEUES_PROVIDER_ROOT: &str = "/queues";
|
||||
|
||||
/// Reserved folder name of the automatically maintained queue.
|
||||
pub const CURRENT_QUEUE_NAME: &str = "current";
|
||||
|
||||
/// Hidden per-queue sidecar carrying [`QueueState`]. Dot-prefixed, so
|
||||
/// library listings never show it.
|
||||
pub const STATE_FILE_NAME: &str = ".queue-state.toml";
|
||||
|
||||
/// The queues directory: `queues/` inside the crabidy config directory.
|
||||
/// `None` when the platform has no config directory.
|
||||
pub fn queues_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("crabidy").join("queues"))
|
||||
}
|
||||
|
||||
/// Everything the playback loop knows about the queue that is worth
|
||||
/// persisting. Sent through the persister's `watch` channel (latest wins)
|
||||
/// and written by [`QueueStore`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct QueueSnapshot {
|
||||
/// Queue entries in track order (not play order — shuffle order is
|
||||
/// deliberately not persisted).
|
||||
pub tracks: Vec<Track>,
|
||||
/// Index of the current track in `tracks`.
|
||||
pub current_position: u32,
|
||||
pub repeat: bool,
|
||||
pub shuffle: bool,
|
||||
}
|
||||
|
||||
/// The on-disk schema of the [`STATE_FILE_NAME`] sidecar.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct QueueState {
|
||||
pub current_position: u32,
|
||||
pub repeat: bool,
|
||||
pub shuffle: bool,
|
||||
}
|
||||
|
||||
/// Errors from validating or writing a persisted queue.
|
||||
///
|
||||
/// At the RPC boundary: `InvalidName` → `invalid_argument`, `EmptyQueue` →
|
||||
/// `failed_precondition`, the rest → `internal`. Messages carry names and
|
||||
/// paths, never file contents.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SaveQueueError {
|
||||
#[error("invalid queue name: {0}")]
|
||||
InvalidName(&'static str),
|
||||
#[error("the queue is empty")]
|
||||
EmptyQueue,
|
||||
#[error("queue persistence is disabled")]
|
||||
Disabled,
|
||||
#[error("cannot write queue: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
TrackFile(#[from] fsdy::TrackFileError),
|
||||
#[error("cannot serialize queue state: {0}")]
|
||||
State(#[from] toml::ser::Error),
|
||||
}
|
||||
|
||||
/// Reads and writes persisted queue folders. Cheap to clone behind an
|
||||
/// `Arc`; all I/O is `tokio::fs`.
|
||||
#[derive(Debug)]
|
||||
pub struct QueueStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl QueueStore {
|
||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||
/// missing.
|
||||
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
/// The store directory (what the `/queues` provider instance mounts).
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Validates a user-supplied queue name, returning the trimmed name.
|
||||
///
|
||||
/// Rejected: empty after trimming, containing `/`, `\` or NUL, starting
|
||||
/// with a dot (hidden folders are invisible to listings), and the
|
||||
/// reserved [`CURRENT_QUEUE_NAME`].
|
||||
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
|
||||
// The shared fs-provider naming rules, with the auto-persisted
|
||||
// queue's folder reserved.
|
||||
fsdy::validate_folder_name(name, &[CURRENT_QUEUE_NAME]).map_err(SaveQueueError::InvalidName)
|
||||
}
|
||||
|
||||
/// Saves `snapshot` as the named queue, overwriting an existing one.
|
||||
///
|
||||
/// Validates `name` per [`Self::validate_name`] and rejects an empty
|
||||
/// snapshot with [`SaveQueueError::EmptyQueue`]. The folder is written
|
||||
/// to a hidden temp sibling first, then swapped into place (remove old,
|
||||
/// rename) — a crash can lose the folder, never corrupt it half-written
|
||||
/// next to intact files.
|
||||
pub async fn save(&self, name: &str, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
||||
let name = Self::validate_name(name)?;
|
||||
if snapshot.tracks.is_empty() {
|
||||
return Err(SaveQueueError::EmptyQueue);
|
||||
}
|
||||
self.write_queue_dir(name, snapshot).await
|
||||
}
|
||||
|
||||
/// Persists `snapshot` as the current queue ([`CURRENT_QUEUE_NAME`]).
|
||||
///
|
||||
/// Same write path as [`Self::save`] but without name validation and
|
||||
/// with an empty snapshot allowed — clearing the queue must persist as
|
||||
/// cleared.
|
||||
pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
||||
self.write_queue_dir(CURRENT_QUEUE_NAME, snapshot).await
|
||||
}
|
||||
|
||||
/// The shared write path: build the whole folder as a hidden temp
|
||||
/// sibling, then swap it into place (remove old, rename). A crash can
|
||||
/// lose the folder, never leave it half-written next to intact files
|
||||
/// (architecture/queue-persistence.md D3).
|
||||
async fn write_queue_dir(
|
||||
&self,
|
||||
name: &str,
|
||||
snapshot: &QueueSnapshot,
|
||||
) -> Result<(), SaveQueueError> {
|
||||
let tmp = self.dir.join(format!(".tmp-{name}"));
|
||||
// A leftover temp folder from a crashed or racing write is stale.
|
||||
if tokio::fs::try_exists(&tmp).await? {
|
||||
tokio::fs::remove_dir_all(&tmp).await?;
|
||||
}
|
||||
tokio::fs::create_dir_all(&tmp).await?;
|
||||
for (index, track) in snapshot.tracks.iter().enumerate() {
|
||||
let text = fsdy::TrackFile::from_track(track).to_toml()?;
|
||||
let file = tmp.join(fsdy::track_file_name(index, &track.title));
|
||||
tokio::fs::write(file, text).await?;
|
||||
}
|
||||
let state = QueueState {
|
||||
current_position: snapshot.current_position,
|
||||
repeat: snapshot.repeat,
|
||||
shuffle: snapshot.shuffle,
|
||||
};
|
||||
tokio::fs::write(tmp.join(STATE_FILE_NAME), toml::to_string_pretty(&state)?).await?;
|
||||
|
||||
let target = self.dir.join(name);
|
||||
if tokio::fs::try_exists(&target).await? {
|
||||
tokio::fs::remove_dir_all(&target).await?;
|
||||
}
|
||||
tokio::fs::rename(&tmp, &target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads the persisted current queue for the startup restore.
|
||||
///
|
||||
/// Reads the folder like a library listing (sorted case-insensitively,
|
||||
/// broken/hidden/foreign entries skipped with warnings) plus the
|
||||
/// [`QueueState`] sidecar (missing or broken sidecar → default state).
|
||||
/// `None` when the folder does not exist — a fresh start. Never fails
|
||||
/// the server; every defect is a warning and degrades to less state.
|
||||
pub async fn load_current(&self) -> Option<QueueSnapshot> {
|
||||
let dir = self.dir.join(CURRENT_QUEUE_NAME);
|
||||
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(read_dir) => read_dir,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(err) => {
|
||||
warn!(dir = %dir.display(), "cannot read the persisted queue: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror the provider listing: visible regular `*.cbd-track.toml`
|
||||
// files, sorted case-insensitively — restore order == listing order.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
loop {
|
||||
let entry = match read_dir.next_entry().await {
|
||||
Ok(Some(entry)) => entry,
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
warn!(dir = %dir.display(), "error while reading the persisted queue: {err}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let is_file = entry
|
||||
.file_type()
|
||||
.await
|
||||
.is_ok_and(|file_type| file_type.is_file());
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
warn!(dir = %dir.display(), "skipping queue entry with non-UTF-8 name");
|
||||
continue;
|
||||
};
|
||||
if is_file && !name.starts_with('.') && name.ends_with(fsdy::TRACK_FILE_SUFFIX) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|name| name.to_lowercase());
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
for name in names {
|
||||
let file = dir.join(&name);
|
||||
let text = match tokio::fs::read_to_string(&file).await {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
warn!(file = %file.display(), "cannot read queue entry: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match fsdy::TrackFile::parse(&text) {
|
||||
Ok(track_file) => {
|
||||
// The same library path the /queues listing would give
|
||||
// the entry, so non-link playables behave identically.
|
||||
let lib_path = crabidy_core::join_path(
|
||||
&crabidy_core::join_path(QUEUES_PROVIDER_ROOT, CURRENT_QUEUE_NAME),
|
||||
&crabidy_core::encode_segment(&name),
|
||||
);
|
||||
tracks.push(track_file.to_track(&lib_path));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(file = %file.display(), "skipping invalid queue entry: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state_file = dir.join(STATE_FILE_NAME);
|
||||
let state = match tokio::fs::read_to_string(&state_file).await {
|
||||
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
|
||||
warn!(file = %state_file.display(), "broken queue state, using defaults: {err}");
|
||||
QueueState::default()
|
||||
}),
|
||||
Err(err) => {
|
||||
debug!(file = %state_file.display(), "no queue state, using defaults: {err}");
|
||||
QueueState::default()
|
||||
}
|
||||
};
|
||||
Some(QueueSnapshot {
|
||||
tracks,
|
||||
current_position: state.current_position,
|
||||
repeat: state.repeat,
|
||||
shuffle: state.shuffle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the auto-persist task: awaits snapshot changes on `rx`, debounces
|
||||
/// briefly to coalesce bursts (resolve chunks), skips writes whose snapshot
|
||||
/// equals the last one written, and rewrites the current queue folder.
|
||||
/// Write failures are warnings; the task never affects playback. Exits when
|
||||
/// the sender side is dropped.
|
||||
pub fn spawn_persister(
|
||||
store: Arc<QueueStore>,
|
||||
mut rx: tokio::sync::watch::Receiver<Option<QueueSnapshot>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut last_written: Option<QueueSnapshot> = None;
|
||||
while rx.changed().await.is_ok() {
|
||||
// Debounce: a resolve streams many chunks in quick succession;
|
||||
// the watch channel keeps only the newest snapshot, so waiting
|
||||
// briefly coalesces the burst into one write.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let Some(snapshot) = rx.borrow_and_update().clone() else {
|
||||
continue;
|
||||
};
|
||||
// Broadcasts that only toggled the `resolving` flag carry an
|
||||
// unchanged snapshot — skip the write.
|
||||
if last_written.as_ref() == Some(&snapshot) {
|
||||
continue;
|
||||
}
|
||||
match store.persist_current(&snapshot).await {
|
||||
Ok(()) => last_written = Some(snapshot),
|
||||
Err(err) => warn!("cannot persist the current queue: {err}"),
|
||||
}
|
||||
}
|
||||
debug!("queue snapshot channel closed, persister exiting");
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crabidy_core::proto::crabidy::Album;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn track(i: usize) -> Track {
|
||||
Track {
|
||||
path: format!("/tidal/playlists/p/{i}"),
|
||||
artist: "artist".to_string(),
|
||||
title: format!("track {i}"),
|
||||
duration: Some(60 + i as u32),
|
||||
album: Some(Album {
|
||||
title: "album".to_string(),
|
||||
release_date: None,
|
||||
}),
|
||||
is_skipped: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(n: usize) -> QueueSnapshot {
|
||||
QueueSnapshot {
|
||||
tracks: (0..n).map(track).collect(),
|
||||
current_position: 0,
|
||||
repeat: false,
|
||||
shuffle: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn store() -> (QueueStore, TempDir) {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let store = QueueStore::open(dir.path().join("queues"))
|
||||
.await
|
||||
.expect("open creates the directory");
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
/// Sorted visible file names of a queue folder.
|
||||
fn visible_files(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.expect("queue folder")
|
||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_ordered_link_files_and_the_sidecar() {
|
||||
let (store, _dir) = store().await;
|
||||
let mut snap = snapshot(3);
|
||||
// A queue may hold fs tracks; persisting must link to them too.
|
||||
snap.tracks[1].path = "/fs/mix/song.cbd-track.toml".to_string();
|
||||
store.save("road trip", &snap).await.expect("save");
|
||||
|
||||
let queue_dir = store.dir().join("road trip");
|
||||
let names = visible_files(&queue_dir);
|
||||
assert_eq!(names.len(), 3);
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
assert!(name.starts_with(&format!("{:04} ", i + 1)), "{name}");
|
||||
let text = std::fs::read_to_string(queue_dir.join(name)).expect("read entry");
|
||||
let file = fsdy::TrackFile::parse(&text).expect("entry parses");
|
||||
// The listing rewrite restores the original track exactly.
|
||||
assert_eq!(file.to_track("/queues/irrelevant"), snap.tracks[i]);
|
||||
}
|
||||
assert!(
|
||||
queue_dir.join(STATE_FILE_NAME).exists(),
|
||||
"sidecar written (hidden from listings by its dot prefix)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_validates_names_and_rejects_an_empty_queue() {
|
||||
let (store, _dir) = store().await;
|
||||
for bad in ["", " ", "a/b", "a\\b", ".hidden", CURRENT_QUEUE_NAME] {
|
||||
assert!(
|
||||
matches!(
|
||||
store.save(bad, &snapshot(1)).await,
|
||||
Err(SaveQueueError::InvalidName(_))
|
||||
),
|
||||
"name {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
store.save("fine", &snapshot(0)).await,
|
||||
Err(SaveQueueError::EmptyQueue)
|
||||
));
|
||||
// A valid name is used trimmed.
|
||||
store
|
||||
.save(" padded ", &snapshot(1))
|
||||
.await
|
||||
.expect("trimmed name saves");
|
||||
assert!(store.dir().join("padded").is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_overwrites_an_existing_queue_completely() {
|
||||
let (store, _dir) = store().await;
|
||||
store.save("mix", &snapshot(3)).await.expect("first save");
|
||||
store.save("mix", &snapshot(1)).await.expect("overwrite");
|
||||
// No stale entries from the longer first save survive.
|
||||
assert_eq!(visible_files(&store.dir().join("mix")).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_current_and_load_round_trip() {
|
||||
let (store, _dir) = store().await;
|
||||
let snap = QueueSnapshot {
|
||||
current_position: 2,
|
||||
repeat: true,
|
||||
shuffle: true,
|
||||
..snapshot(4)
|
||||
};
|
||||
store.persist_current(&snap).await.expect("persist");
|
||||
let loaded = store.load_current().await.expect("load");
|
||||
assert_eq!(loaded, snap);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_current_accepts_an_empty_queue() {
|
||||
// Clearing the queue must persist as cleared, not keep yesterday's
|
||||
// tracks for the next restart.
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.persist_current(&snapshot(2))
|
||||
.await
|
||||
.expect("non-empty");
|
||||
store.persist_current(&snapshot(0)).await.expect("empty");
|
||||
let loaded = store.load_current().await.expect("load");
|
||||
assert!(loaded.tracks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_current_without_a_folder_is_a_fresh_start() {
|
||||
let (store, _dir) = store().await;
|
||||
assert!(store.load_current().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_current_skips_broken_entries_and_survives_a_broken_sidecar() {
|
||||
let (store, _dir) = store().await;
|
||||
store.persist_current(&snapshot(2)).await.expect("persist");
|
||||
let current = store.dir().join(CURRENT_QUEUE_NAME);
|
||||
std::fs::write(current.join("0000 broken.cbd-track.toml"), "not [ toml")
|
||||
.expect("write broken entry");
|
||||
std::fs::write(current.join(STATE_FILE_NAME), "also not [ toml")
|
||||
.expect("break the sidecar");
|
||||
let loaded = store.load_current().await.expect("load");
|
||||
// The two good tracks load; the broken entry is skipped and the
|
||||
// broken sidecar degrades to default state instead of failing.
|
||||
assert_eq!(loaded.tracks.len(), 2);
|
||||
assert_eq!(loaded.current_position, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persister_writes_the_latest_snapshot() {
|
||||
let (store, _dir) = store().await;
|
||||
let store = Arc::new(store);
|
||||
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||
spawn_persister(Arc::clone(&store), rx);
|
||||
// A burst: only the newest snapshot matters (latest-wins channel).
|
||||
tx.send(Some(snapshot(5))).expect("send");
|
||||
tx.send(Some(snapshot(3))).expect("send");
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(loaded) = store.load_current().await {
|
||||
if loaded.tracks.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"persister never wrote the latest snapshot"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
use crate::bookmark_store::CaptureError;
|
||||
use crate::queue_store::SaveQueueError;
|
||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
||||
ClearQueueResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, GetUpdateStreamRequest,
|
||||
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, InsertResponse, NextRequest,
|
||||
NextResponse, PrevRequest, PrevResponse, QueueRequest, QueueResponse, RemoveRequest,
|
||||
RemoveResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RestartTrackResponse,
|
||||
SaveQueueRequest, SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest,
|
||||
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
|
||||
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
||||
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
|
||||
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
|
||||
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest,
|
||||
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse,
|
||||
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
||||
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
|
||||
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
|
||||
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
|
||||
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SetCurrentRequest,
|
||||
SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse,
|
||||
TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse,
|
||||
ToggleShuffleRequest, ToggleShuffleResponse,
|
||||
};
|
||||
use crabidy_core::ProviderError;
|
||||
use std::pin::Pin;
|
||||
use tokio_stream::StreamExt;
|
||||
use tonic::{Request, Response, Status};
|
||||
|
|
@ -99,6 +106,136 @@ impl CrabidyService for RpcService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Creates a node under a creatable parent via the provider loop.
|
||||
///
|
||||
/// Error mapping is part of the contract: `NotSupported` →
|
||||
/// `failed_precondition`, `InvalidInput` → `invalid_argument`, everything
|
||||
/// else `internal`.
|
||||
#[instrument(skip(self, request), fields(parent_path, title))]
|
||||
async fn create_library_node(
|
||||
&self,
|
||||
request: Request<CreateLibraryNodeRequest>,
|
||||
) -> Result<Response<CreateLibraryNodeResponse>, Status> {
|
||||
let CreateLibraryNodeRequest { parent_path, title } = request.into_inner();
|
||||
tracing::Span::current().record("parent_path", parent_path.as_str());
|
||||
tracing::Span::current().record("title", title.as_str());
|
||||
debug!("received create_library_node request");
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
self.provider_tx
|
||||
.send_async(ProviderMessage::new(ProviderCommand::CreateLibraryNode {
|
||||
parent_path,
|
||||
title,
|
||||
result_tx,
|
||||
}))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("provider channel closed: {err}");
|
||||
Status::internal("provider unavailable")
|
||||
})?;
|
||||
let result = result_rx.recv_async().await.map_err(|err| {
|
||||
error!("no reply from provider: {err}");
|
||||
Status::internal("provider did not reply")
|
||||
})?;
|
||||
match result {
|
||||
Ok(node) => Ok(Response::new(CreateLibraryNodeResponse {
|
||||
node: Some(node),
|
||||
})),
|
||||
Err(ProviderError::NotSupported) => Err(Status::failed_precondition(
|
||||
"this node does not support creating children",
|
||||
)),
|
||||
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node title")),
|
||||
Err(err) => {
|
||||
error!("create_library_node failed: {err}");
|
||||
Err(Status::internal(err.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames an editable node via the provider loop. Same error mapping as
|
||||
/// `create_library_node`: `NotSupported` → `failed_precondition`,
|
||||
/// `InvalidInput` → `invalid_argument`, everything else `internal`.
|
||||
#[instrument(skip(self, request), fields(path, new_title))]
|
||||
async fn rename_library_node(
|
||||
&self,
|
||||
request: Request<RenameLibraryNodeRequest>,
|
||||
) -> Result<Response<RenameLibraryNodeResponse>, Status> {
|
||||
let RenameLibraryNodeRequest { path, new_title } = request.into_inner();
|
||||
tracing::Span::current().record("path", path.as_str());
|
||||
tracing::Span::current().record("new_title", new_title.as_str());
|
||||
debug!("received rename_library_node request");
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
self.provider_tx
|
||||
.send_async(ProviderMessage::new(ProviderCommand::RenameLibraryNode {
|
||||
path,
|
||||
new_title,
|
||||
result_tx,
|
||||
}))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("provider channel closed: {err}");
|
||||
Status::internal("provider unavailable")
|
||||
})?;
|
||||
let result = result_rx.recv_async().await.map_err(|err| {
|
||||
error!("no reply from provider: {err}");
|
||||
Status::internal("provider did not reply")
|
||||
})?;
|
||||
match result {
|
||||
Ok(node) => Ok(Response::new(RenameLibraryNodeResponse {
|
||||
node: Some(node),
|
||||
})),
|
||||
Err(ProviderError::NotSupported) => {
|
||||
Err(Status::failed_precondition("this node cannot be renamed"))
|
||||
}
|
||||
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node title")),
|
||||
Err(err) => {
|
||||
error!("rename_library_node failed: {err}");
|
||||
Err(Status::internal(err.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a deletable node via the provider loop and returns the
|
||||
/// refreshed parent. Same error mapping as `create_library_node`.
|
||||
#[instrument(skip(self, request), fields(path))]
|
||||
async fn delete_library_node(
|
||||
&self,
|
||||
request: Request<DeleteLibraryNodeRequest>,
|
||||
) -> Result<Response<DeleteLibraryNodeResponse>, Status> {
|
||||
let DeleteLibraryNodeRequest { path } = request.into_inner();
|
||||
tracing::Span::current().record("path", path.as_str());
|
||||
debug!("received delete_library_node request");
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
self.provider_tx
|
||||
.send_async(ProviderMessage::new(ProviderCommand::DeleteLibraryNode {
|
||||
path,
|
||||
result_tx,
|
||||
}))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("provider channel closed: {err}");
|
||||
Status::internal("provider unavailable")
|
||||
})?;
|
||||
let result = result_rx.recv_async().await.map_err(|err| {
|
||||
error!("no reply from provider: {err}");
|
||||
Status::internal("provider did not reply")
|
||||
})?;
|
||||
match result {
|
||||
Ok(parent) => Ok(Response::new(DeleteLibraryNodeResponse {
|
||||
parent: Some(parent),
|
||||
})),
|
||||
Err(ProviderError::NotSupported) => {
|
||||
Err(Status::failed_precondition("this node cannot be deleted"))
|
||||
}
|
||||
// No provider raises this for delete today; mapped anyway so the
|
||||
// contract stays uniform across the node-mutation rpcs.
|
||||
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node path")),
|
||||
Err(err) => {
|
||||
error!("delete_library_node failed: {err}");
|
||||
Err(Status::internal(err.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, request), fields(paths))]
|
||||
async fn queue(
|
||||
&self,
|
||||
|
|
@ -239,13 +376,115 @@ impl CrabidyService for RpcService {
|
|||
Ok(Response::new(Box::pin(output_stream)))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, _request))]
|
||||
/// Captures a queueable subtree as a bookmark via the provider loop
|
||||
/// (structure-preserving snapshot under `/bookmarks/<name>`), or —
|
||||
/// with `download` — as a download capture under `/captures/<name>`
|
||||
/// (incremental: an existing name is resumed, not overwritten).
|
||||
///
|
||||
/// Returns once the capture is **accepted**: the reply covers
|
||||
/// validation only, the walk runs detached and reports through
|
||||
/// `CaptureProgress` events on the update stream (forwarded here from
|
||||
/// the provider's progress channel), ending in one `finished` event.
|
||||
///
|
||||
/// Error mapping is part of the contract: invalid name or
|
||||
/// uncapturable source → `invalid_argument`; a disabled store or a
|
||||
/// source that does not allow downloads → `failed_precondition`.
|
||||
/// Walk/write/download failures happen after the reply and surface in
|
||||
/// the terminal progress event (and the server log).
|
||||
#[instrument(skip(self, request), fields(path, name, download))]
|
||||
async fn capture_library_node(
|
||||
&self,
|
||||
request: Request<CaptureLibraryNodeRequest>,
|
||||
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
||||
let CaptureLibraryNodeRequest {
|
||||
path,
|
||||
name,
|
||||
download,
|
||||
} = request.into_inner();
|
||||
tracing::Span::current().record("path", path.as_str());
|
||||
tracing::Span::current().record("name", name.as_str());
|
||||
tracing::Span::current().record("download", download);
|
||||
debug!("received capture_library_node request");
|
||||
// The walk's progress events fan out to every connected client via
|
||||
// the update broadcast; the forwarder dies with the walk's terminal
|
||||
// event (the provider drops the sender).
|
||||
let (progress_tx, progress_rx) = flume::bounded(64);
|
||||
let update_tx = self.update_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(progress) = progress_rx.recv_async().await {
|
||||
// No subscribers is normal (e.g. no client connected).
|
||||
let _ = update_tx.send(StreamUpdate::CaptureProgress(progress));
|
||||
}
|
||||
});
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
self.provider_tx
|
||||
.send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode {
|
||||
path,
|
||||
name,
|
||||
download,
|
||||
progress_tx,
|
||||
result_tx,
|
||||
}))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("provider channel closed: {err}");
|
||||
Status::internal("provider unavailable")
|
||||
})?;
|
||||
let result = result_rx.recv_async().await.map_err(|err| {
|
||||
error!("no reply from provider: {err}");
|
||||
Status::internal("provider did not reply")
|
||||
})?;
|
||||
match result {
|
||||
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
||||
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
|
||||
Err(Status::invalid_argument(err.to_string()))
|
||||
}
|
||||
Err(
|
||||
err @ (CaptureError::TooLarge(_)
|
||||
| CaptureError::Disabled
|
||||
| CaptureError::Unsupported),
|
||||
) => Err(Status::failed_precondition(err.to_string())),
|
||||
Err(err) => {
|
||||
error!("capture_library_node failed: {err}");
|
||||
Err(Status::internal("cannot capture the subtree"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the current queue under a name (persisted queues, visible as
|
||||
/// `/queues/<name>` in the library).
|
||||
///
|
||||
/// Error mapping is part of the contract: an invalid name →
|
||||
/// `invalid_argument`; an empty queue or disabled persistence →
|
||||
/// `failed_precondition`; I/O and serialization failures → `internal`.
|
||||
#[instrument(skip(self, request), fields(name))]
|
||||
async fn save_queue(
|
||||
&self,
|
||||
_request: Request<SaveQueueRequest>,
|
||||
request: Request<SaveQueueRequest>,
|
||||
) -> Result<Response<SaveQueueResponse>, Status> {
|
||||
debug!("received save_queue request (not implemented)");
|
||||
Ok(Response::new(SaveQueueResponse {}))
|
||||
let name = request.into_inner().name;
|
||||
tracing::Span::current().record("name", name.as_str());
|
||||
debug!("received save_queue request");
|
||||
let (result_tx, result_rx) = flume::bounded(1);
|
||||
self.send_playback(PlaybackCommand::SaveQueue { name, result_tx })
|
||||
.await?;
|
||||
let result = result_rx.recv_async().await.map_err(|err| {
|
||||
error!("no reply from playback loop: {err}");
|
||||
Status::internal("playback loop did not reply")
|
||||
})?;
|
||||
match result {
|
||||
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
||||
Err(err @ SaveQueueError::InvalidName(_)) => {
|
||||
Err(Status::invalid_argument(err.to_string()))
|
||||
}
|
||||
Err(err @ (SaveQueueError::EmptyQueue | SaveQueueError::Disabled)) => {
|
||||
Err(Status::failed_precondition(err.to_string()))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("save_queue failed: {err}");
|
||||
Err(Status::internal("cannot save the queue"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, _request))]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
|
||||
//!
|
||||
//! Today this only carries the `[auth]` role hashes
|
||||
//! (architecture/roles-auth.md). The file is optional — a missing file
|
||||
//! runs the server open, exactly as before the feature — but a file
|
||||
//! that exists and does not parse aborts startup: silently ignoring a
|
||||
//! broken auth config would run an intended-to-be-locked server open
|
||||
//! (fail-closed, quality/roles-auth.md).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The server config file name inside the crabidy config directory.
|
||||
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||
|
||||
/// Contents of `crabidy-server.toml`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ServerSettings {
|
||||
/// Role credentials; absent (or empty) means the server runs open.
|
||||
#[serde(default)]
|
||||
pub auth: AuthSettings,
|
||||
}
|
||||
|
||||
/// One PHC password hash per role; a role without a hash cannot
|
||||
/// authenticate. Generate hashes with `crabidy-server hash-password`.
|
||||
/// Hashes are not passwords, but the file should stay private anyway.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthSettings {
|
||||
pub owner: Option<String>,
|
||||
pub queue_owner: Option<String>,
|
||||
pub queue_appender: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthSettings {
|
||||
/// Whether any role is credentialed — the switch that turns
|
||||
/// authentication on for every RPC.
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerSettings {
|
||||
/// Loads the settings from `config_dir`.
|
||||
///
|
||||
/// A missing file yields the defaults (auth disabled). An existing
|
||||
/// file that cannot be read or parsed is an error — the caller
|
||||
/// must abort startup rather than run open.
|
||||
pub fn load(config_dir: &Path) -> Result<Self, String> {
|
||||
let file = config_dir.join(SETTINGS_FILE);
|
||||
let raw = match std::fs::read_to_string(&file) {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
|
||||
};
|
||||
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_disables_auth() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let settings = ServerSettings::load(dir.path()).expect("defaults");
|
||||
assert!(!settings.auth.enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_load_and_enable_auth() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
std::fs::write(
|
||||
dir.path().join(SETTINGS_FILE),
|
||||
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
|
||||
)
|
||||
.expect("write");
|
||||
let settings = ServerSettings::load(dir.path()).expect("parse");
|
||||
assert!(settings.auth.enabled());
|
||||
assert_eq!(
|
||||
settings.auth.queue_appender.as_deref(),
|
||||
Some("$argon2id$fake")
|
||||
);
|
||||
assert!(settings.auth.owner.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_file_is_a_startup_error_not_an_open_server() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
for bad in [
|
||||
"[auth\n",
|
||||
"[auth]\nowner = 3\n",
|
||||
"[auth]\nonwer = \"typo\"\n",
|
||||
] {
|
||||
std::fs::write(dir.path().join(SETTINGS_FILE), bad).expect("write");
|
||||
let err = ServerSettings::load(dir.path()).expect_err(bad);
|
||||
assert!(err.contains("crabidy-server.toml"), "{err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
//! Turns a window of audio samples into a handful of normalized
|
||||
//! frequency bars (architecture/spectrum.md). Pure DSP — the tap
|
||||
//! (`audio-player`) supplies samples, the stream carries the result;
|
||||
//! this only does the maths.
|
||||
|
||||
use realfft::num_complex::Complex;
|
||||
use realfft::{RealFftPlanner, RealToComplex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Number of bars the visualizer shows.
|
||||
pub const SPECTRUM_BINS: usize = 24;
|
||||
|
||||
/// Magnitudes below this (dBFS) map to an empty bar; 0 dB maps to full.
|
||||
const MIN_DB: f32 = -60.0;
|
||||
|
||||
/// A reusable forward-FFT + log-bin folder for a fixed window length.
|
||||
pub struct SpectrumAnalyzer {
|
||||
fft: Arc<dyn RealToComplex<f32>>,
|
||||
/// Hann window applied before the transform to cut spectral leakage.
|
||||
window: Vec<f32>,
|
||||
input: Vec<f32>,
|
||||
output: Vec<Complex<f32>>,
|
||||
/// `SPECTRUM_BINS + 1` boundaries into the magnitude array, spaced
|
||||
/// logarithmically so the bars are roughly musically even.
|
||||
edges: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SpectrumAnalyzer {
|
||||
pub fn new(window_len: usize) -> Self {
|
||||
let fft = RealFftPlanner::<f32>::new().plan_fft_forward(window_len);
|
||||
let input = fft.make_input_vec();
|
||||
let output = fft.make_output_vec();
|
||||
let window = (0..window_len).map(|i| hann(i, window_len)).collect();
|
||||
let edges = log_bin_edges(output.len(), SPECTRUM_BINS);
|
||||
Self {
|
||||
fft,
|
||||
window,
|
||||
input,
|
||||
output,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds `samples` (exactly the window length) into `SPECTRUM_BINS`
|
||||
/// normalized `[0, 1]` magnitudes, low frequency first. A processing
|
||||
/// error or a wrong-length input yields all-zero bars rather than a
|
||||
/// panic.
|
||||
pub fn analyze(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||
if samples.len() != self.input.len() {
|
||||
return vec![0.0; SPECTRUM_BINS];
|
||||
}
|
||||
for (dst, (sample, w)) in self
|
||||
.input
|
||||
.iter_mut()
|
||||
.zip(samples.iter().zip(self.window.iter()))
|
||||
{
|
||||
*dst = sample * w;
|
||||
}
|
||||
if self.fft.process(&mut self.input, &mut self.output).is_err() {
|
||||
return vec![0.0; SPECTRUM_BINS];
|
||||
}
|
||||
let n = self.window.len() as f32;
|
||||
// Single-sided amplitude (2/N): a full-scale tone reads near 1.0
|
||||
// at its bin, so the bars fill for ordinary listening levels.
|
||||
let magnitude = |c: &Complex<f32>| c.norm() * 2.0 / n;
|
||||
(0..SPECTRUM_BINS)
|
||||
.map(|bar| {
|
||||
let lo = self.edges[bar];
|
||||
let hi = self.edges[bar + 1].max(lo + 1).min(self.output.len());
|
||||
// Peak within the band, not the mean: a strong component
|
||||
// must light its bar instead of being diluted by the
|
||||
// quiet bins around it (which averaging does).
|
||||
let peak = self.output[lo..hi]
|
||||
.iter()
|
||||
.map(magnitude)
|
||||
.fold(0.0_f32, f32::max);
|
||||
// Log-compress: dBFS mapped onto [0, 1].
|
||||
let db = 20.0 * (peak + 1e-9).log10();
|
||||
((db - MIN_DB) / -MIN_DB).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Hann window coefficient for sample `i` of `len`.
|
||||
fn hann(i: usize, len: usize) -> f32 {
|
||||
let x = std::f32::consts::PI * i as f32 / (len - 1) as f32;
|
||||
x.sin().powi(2)
|
||||
}
|
||||
|
||||
/// Geometrically-spaced boundaries into a magnitude array of `mag_len`
|
||||
/// (skipping the DC bin at 0), giving `bins` groups. Monotonic
|
||||
/// non-decreasing; callers widen any empty group to at least one bin.
|
||||
fn log_bin_edges(mag_len: usize, bins: usize) -> Vec<usize> {
|
||||
let lo = 1.0_f32;
|
||||
let hi = (mag_len - 1).max(2) as f32;
|
||||
(0..=bins)
|
||||
.map(|b| {
|
||||
let t = b as f32 / bins as f32;
|
||||
(lo * (hi / lo).powf(t)).round() as usize
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WINDOW: usize = 2048;
|
||||
|
||||
fn sine(freq: f32, sample_rate: f32, len: usize) -> Vec<f32> {
|
||||
(0..len)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_all_zero_bars() {
|
||||
let mut a = SpectrumAnalyzer::new(WINDOW);
|
||||
let bars = a.analyze(&vec![0.0; WINDOW]);
|
||||
assert_eq!(bars.len(), SPECTRUM_BINS);
|
||||
assert!(bars.iter().all(|&b| b == 0.0), "{bars:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tone_lights_one_region_and_stays_normalized() {
|
||||
let mut a = SpectrumAnalyzer::new(WINDOW);
|
||||
// ~1 kHz at 44.1 kHz lands in the upper-middle of the log bars.
|
||||
let bars = a.analyze(&sine(1000.0, 44_100.0, WINDOW));
|
||||
assert_eq!(bars.len(), SPECTRUM_BINS);
|
||||
assert!(bars.iter().all(|&b| (0.0..=1.0).contains(&b)), "{bars:?}");
|
||||
let peak = bars
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.unwrap();
|
||||
assert!(
|
||||
peak.1 > 0.5,
|
||||
"a pure tone should drive its bar high: {bars:?}"
|
||||
);
|
||||
// Energy is concentrated: most bars stay well below the peak.
|
||||
let loud = bars.iter().filter(|&&b| b > peak.1 * 0.5).count();
|
||||
assert!(
|
||||
loud <= 4,
|
||||
"a tone should not light the whole spectrum: {bars:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_length_input_is_zero_not_a_panic() {
|
||||
let mut a = SpectrumAnalyzer::new(WINDOW);
|
||||
assert!(a.analyze(&[0.1, 0.2, 0.3]).iter().all(|&b| b == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_edges_are_monotonic() {
|
||||
let edges = log_bin_edges(WINDOW / 2 + 1, SPECTRUM_BINS);
|
||||
assert_eq!(edges.len(), SPECTRUM_BINS + 1);
|
||||
assert!(edges.windows(2).all(|w| w[1] >= w[0]), "{edges:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
//! Serves the embedded web client (feature `web-ui`,
|
||||
//! architecture/web-client.md): the trunk bundle staged by `build.rs`
|
||||
//! ships inside the binary and answers every request the gRPC route
|
||||
//! did not claim. Assets are public by design — the app shell is a
|
||||
//! login page at worst; every RPC behind it stays gated by the auth
|
||||
//! layer.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::response::Response;
|
||||
use http::{header, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
/// The staged trunk bundle (or the build.rs placeholder page).
|
||||
static DIST: Dir<'_> = include_dir!("$OUT_DIR/webdist");
|
||||
|
||||
/// Content type by file extension. The bundle is fully known at build
|
||||
/// time, so an unknown extension is a programmer omission — served as
|
||||
/// octet-stream rather than panicking.
|
||||
fn content_type(path: &str) -> &'static str {
|
||||
match path.rsplit_once('.').map(|(_, ext)| ext) {
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("css") => "text/css",
|
||||
Some("js") => "application/javascript",
|
||||
Some("wasm") => "application/wasm",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("txt") => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// The asset for `uri`, falling back to `index.html` for pathless GETs
|
||||
/// (the app owns its own view state; deep links reload the shell).
|
||||
fn lookup(uri: &Uri) -> (&'static str, &'static [u8]) {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
let file = if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
DIST.get_file(path)
|
||||
};
|
||||
match file {
|
||||
Some(file) => (content_type(path), file.contents()),
|
||||
None => (
|
||||
"text/html; charset=utf-8",
|
||||
DIST.get_file("index.html")
|
||||
.map(include_dir::File::contents)
|
||||
// The build script always stages an index.html; an empty
|
||||
// page is the harmless fallback if it ever did not.
|
||||
.unwrap_or(b""),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fallback handler: serves bundle assets for GET/HEAD, 404s
|
||||
/// everything else (non-GET traffic belongs to the gRPC route).
|
||||
pub async fn serve_asset(request: Request<Body>) -> Response {
|
||||
if request.method() != Method::GET && request.method() != Method::HEAD {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("static response");
|
||||
}
|
||||
let (content_type, bytes) = lookup(request.uri());
|
||||
let body = if request.method() == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
Body::from(bytes)
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, HeaderValue::from_static(content_type))
|
||||
.body(body)
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get(path: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.expect("request")
|
||||
}
|
||||
|
||||
async fn body_string(response: Response) -> String {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_root_serves_the_app_shell() {
|
||||
let response = serve_asset(get("/")).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
let html = body_string(response).await;
|
||||
assert!(html.contains("crabidy"), "app shell served");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_paths_fall_back_to_the_shell_get_only() {
|
||||
let response = serve_asset(get("/some/deep/link")).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
|
||||
let post = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/not-grpc")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let response = serve_asset(post).await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_types_cover_the_bundle() {
|
||||
assert_eq!(content_type("a.wasm"), "application/wasm");
|
||||
assert_eq!(content_type("a.js"), "application/javascript");
|
||||
assert_eq!(content_type("a.css"), "text/css");
|
||||
assert_eq!(content_type("weird.bin"), "application/octet-stream");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
//! Integration test for the one-port router composition
|
||||
//! (architecture/web-client.md): static assets, the gRPC-web route, and
|
||||
//! the auth layer must coexist. Driven through `tower::oneshot` so no
|
||||
//! socket, no provider backend, and no Tidal device login are needed.
|
||||
//!
|
||||
//! Only meaningful with the `web-ui` feature (the static fallback and
|
||||
//! gRPC-web layer live behind it); a no-web build has nothing to route.
|
||||
#![cfg(feature = "web-ui")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use crabidy_server::auth::Authenticator;
|
||||
use crabidy_server::rpc::RpcService;
|
||||
use crabidy_server::settings::AuthSettings;
|
||||
use http::{header, Method, Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// A service wired to dead channels: enough to build the router and
|
||||
/// exercise routing and the pre-handler auth layer (the two things
|
||||
/// under test); no RPC that reaches a handler is sent.
|
||||
fn service() -> RpcService {
|
||||
let (update_tx, _) = tokio::sync::broadcast::channel(4);
|
||||
let (playback_tx, _playback_rx) = flume::unbounded();
|
||||
let (provider_tx, _provider_rx) = flume::unbounded();
|
||||
RpcService::new(update_tx, playback_tx, provider_tx)
|
||||
}
|
||||
|
||||
fn hash(password: &str) -> String {
|
||||
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
let params = argon2::Params::new(8, 1, 1, None).expect("params");
|
||||
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
.expect("hash")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_root_serves_the_embedded_app_shell() {
|
||||
let router = crabidy_server::build_router(
|
||||
service(),
|
||||
Arc::new(Authenticator::new(&AuthSettings::default())),
|
||||
);
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let content_type = response.headers()[header::CONTENT_TYPE].to_str().unwrap();
|
||||
assert!(content_type.starts_with("text/html"), "{content_type}");
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&bytes).contains("crabidy"),
|
||||
"app shell (or placeholder) served"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_get_paths_fall_back_to_the_shell() {
|
||||
let router = crabidy_server::build_router(
|
||||
service(),
|
||||
Arc::new(Authenticator::new(&AuthSettings::default())),
|
||||
);
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/library/deep/link")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response.headers()[header::CONTENT_TYPE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("text/html"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grpc_web_calls_route_through_the_auth_layer() {
|
||||
// A credentialed server: an unauthenticated gRPC-web POST must be
|
||||
// rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before*
|
||||
// reaching a handler — so the dead channels never matter.
|
||||
let auth = Authenticator::new(&AuthSettings {
|
||||
owner: Some(hash("pw")),
|
||||
queue_owner: None,
|
||||
queue_appender: None,
|
||||
});
|
||||
let router = crabidy_server::build_router(service(), Arc::new(auth));
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/crabidy.v1.CrabidyService/Init")
|
||||
.header(header::CONTENT_TYPE, "application/grpc-web+proto")
|
||||
.header("x-grpc-web", "1")
|
||||
.body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0]))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
// gRPC-web reports the status in a header (trailers-only), HTTP 200.
|
||||
let grpc_status = response
|
||||
.headers()
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
assert_eq!(grpc_status, Some("16"), "unauthenticated gRPC-web call");
|
||||
|
||||
// With a valid owner credential the layer passes the request
|
||||
// through to the service; owner is allowed for Init, so it is not
|
||||
// rejected. The stub handler then fails on its dead channels — any
|
||||
// outcome other than the auth codes proves the request cleared the
|
||||
// layer and reached the handler.
|
||||
let authed = base64::engine::general_purpose::STANDARD.encode("owner:pw");
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/crabidy.v1.CrabidyService/Init")
|
||||
.header(header::CONTENT_TYPE, "application/grpc-web+proto")
|
||||
.header("x-grpc-web", "1")
|
||||
.header(header::AUTHORIZATION, format!("Basic {authed}"))
|
||||
.body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0]))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let grpc_status = response
|
||||
.headers()
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
assert_ne!(
|
||||
grpc_status,
|
||||
Some("16"),
|
||||
"authorized call must not be UNAUTHENTICATED"
|
||||
);
|
||||
assert_ne!(
|
||||
grpc_status,
|
||||
Some("7"),
|
||||
"owner must not be PERMISSION_DENIED for Init"
|
||||
);
|
||||
}
|
||||
|
||||
/// The TUI speaks native gRPC (HTTP/2 prior knowledge, no TLS). Moving
|
||||
/// the server from `tonic::transport::Server` to `axum::serve` must not
|
||||
/// break that: bind the real router to a socket and call it with a
|
||||
/// native tonic client. A gRPC *status* back (rather than a transport
|
||||
/// error) proves h2c negotiated and the request reached the service.
|
||||
#[tokio::test]
|
||||
async fn native_grpc_still_works_through_the_axum_server() {
|
||||
use crabidy_core::proto::crabidy::crabidy_service_client::CrabidyServiceClient;
|
||||
use crabidy_core::proto::crabidy::GetLibraryNodeRequest;
|
||||
|
||||
let router = crabidy_server::build_router(
|
||||
service(),
|
||||
Arc::new(Authenticator::new(&AuthSettings::default())),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
|
||||
let channel = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
||||
.unwrap()
|
||||
.connect()
|
||||
.await
|
||||
.expect("native h2c connect");
|
||||
let mut client = CrabidyServiceClient::new(channel);
|
||||
// Dead channels make the handler fail fast; we only assert the
|
||||
// round trip produced a gRPC status, i.e. the transport worked.
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
client.get_library_node(GetLibraryNodeRequest {
|
||||
path: "/".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("no transport hang");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"the stub handler errors on dead channels — but the call round-tripped"
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
28
devenv.nix
28
devenv.nix
|
|
@ -14,11 +14,26 @@ let
|
|||
d2
|
||||
pkg-config
|
||||
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
|
||||
{
|
||||
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 = {
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs;
|
||||
};
|
||||
|
|
@ -37,6 +52,19 @@ in
|
|||
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 = "";
|
||||
|
||||
# https://devenv.sh/tasks/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "fsdy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
crabidy-core.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
flume.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# fsdy — the filesystem provider
|
||||
|
||||
Mounts a local directory at **`/fs`** in the crabidy library. The same
|
||||
engine also powers the server-managed mounts `/queues`, `/bookmarks`,
|
||||
and `/captures` — everything below applies to their on-disk format too.
|
||||
|
||||
## How it works
|
||||
|
||||
The provider walks one configured root directory. Every **directory**
|
||||
becomes a queueable library node; every file ending in
|
||||
**`.cbd-track.toml`** becomes a track; everything else (audio files,
|
||||
covers, hidden entries) is invisible to the library. Listing order is
|
||||
case-insensitive by file name — prefix files with numbers to fix an
|
||||
order (the capture/bookmark writers use `0001`-style prefixes for
|
||||
exactly this reason).
|
||||
|
||||
A track file carries the track's metadata plus a reference to the
|
||||
playable thing:
|
||||
|
||||
```toml
|
||||
title = "We Will Rock You" # required
|
||||
artist = "Queen" # optional
|
||||
duration = 122 # optional, seconds
|
||||
|
||||
[album] # optional
|
||||
title = "News of the World"
|
||||
release_date = "1977-10-28"
|
||||
|
||||
# Exactly ONE of the following four:
|
||||
[playable]
|
||||
file = "we-will-rock-you.flac" # local audio; absolute, or relative to
|
||||
# this file's directory (relocatable)
|
||||
# url = "https://example.org/radio.mp3" # http(s) stream
|
||||
# link = "/tidal/artists/1/2/3" # another provider's track
|
||||
# skipped = true # no audio: a capture recorded its
|
||||
# source as uncapturable; shown red,
|
||||
# skipped by playback
|
||||
```
|
||||
|
||||
Malformed track files are skipped with a warning; they never break the
|
||||
listing. `link` playables resolve exactly one hop (a link to a link
|
||||
fails at play time), which keeps cycles impossible.
|
||||
|
||||
## Configuration — `~/.config/crabidy/fsdy.toml`
|
||||
|
||||
```toml
|
||||
# Absolute path of the directory to expose under /fs.
|
||||
# Default: the platform music directory (e.g. ~/Music). When neither is
|
||||
# available the /fs mount is disabled — the rest of the server runs on.
|
||||
# root = "/home/me/music"
|
||||
```
|
||||
|
||||
## The server-managed instances
|
||||
|
||||
`/queues`, `/bookmarks`, and `/captures` are fsdy instances over
|
||||
`~/.config/crabidy/{queues,bookmarks,captures}/`, written by the server
|
||||
(queue persistence, `w`, `W`) and readable/editable with any file
|
||||
manager — they are just folders of the track files described above.
|
||||
Their top-level folders can be renamed (`e`) and deleted (`d`) from the
|
||||
TUI; `/queues/current` is the continuously persisted play queue and is
|
||||
protected.
|
||||
|
||||
`/captures` additionally lets `d` delete *anything* in its tree — a
|
||||
whole capture, a nested album folder, or a single track (which takes
|
||||
its downloaded audio file with it). Because that removes data from
|
||||
disk that was slow to download, the TUI asks for confirmation first
|
||||
(`delete <name>? [y/N]`); deletes elsewhere stay unconfirmed.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,51 @@
|
|||
# Plan: bookmarks
|
||||
|
||||
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs` /
|
||||
`crabidy-server/src/bookmark_store.rs` and/or gates in
|
||||
`quality/bookmarks.md`). Stubs, the proto rpc, and orchestrator wiring
|
||||
exist; the new fsdy/bookmark-store tests fail on `todo!()` at plan time.
|
||||
|
||||
- [x] **T1 — fsdy shared naming.** `dir_name` (prefix + shared sanitizer,
|
||||
no suffix; refactor `track_file_name` onto one helper) and
|
||||
`validate_folder_name` (trim, reject separators/NUL/hidden/reserved).
|
||||
Verifies: `dir_names_share_the_track_file_sanitizer`,
|
||||
`folder_name_validation_trims_and_rejects`; gate "no second naming
|
||||
scheme".
|
||||
- [x] **T2 — fsdy mutable top level.** Flags on the editable-instance root
|
||||
listing (skip reserved); `rename_lib_node`/`delete_lib_node`
|
||||
implementations gated on option + direct child + not reserved
|
||||
(`NotSupported` otherwise); rename validates via
|
||||
`validate_folder_name`, refuses collisions (`InvalidInput`), returns
|
||||
the renamed node; delete is idempotent and returns the root listing.
|
||||
Verifies: `editable_instances_flag_only_unreserved_top_level_folders`,
|
||||
`rename_moves_a_top_level_folder`,
|
||||
`rename_rejects_reserved_invalid_and_colliding_targets`,
|
||||
`delete_removes_top_level_folders_idempotently`; gates "Mutable top
|
||||
level".
|
||||
- [x] **T3 — queue_store reuse.** `QueueStore::validate_name` delegates to
|
||||
`fsdy::validate_folder_name` with `current` reserved (behavior
|
||||
unchanged — existing tests must stay green).
|
||||
- [x] **T4 — BookmarkStore capture.** `open`, `capture_with_caps`
|
||||
(iterative pre-order walk over `get_lib_node` /
|
||||
`get_metadata_for_track`, mirrored dirs + link files, caps, temp
|
||||
cleanup on every failure, tmp-and-swap). Verifies: all
|
||||
`bookmark_store::tests`; gates "Capture".
|
||||
- [x] **T5 — TUI capture flow.** `Action::LibraryCaptureNode` (`w`,
|
||||
`Scope::Library`), `Library::selected_queueable()` (bare selection,
|
||||
queueable, path + title), `InputPurpose::Capture { path }` prefilled
|
||||
with the title, `MessageFromUi::CaptureNode { path, name }`,
|
||||
orchestrator arm → new `rpc::capture_library_node` (log failures, keep
|
||||
polling). TUI tests: binding lookup, overlay gating + prefill, submit
|
||||
message. Verifies: new `cbd-tui` tests; gates "TUI".
|
||||
- [x] **T6 — full verification.** Whole workspace suite green;
|
||||
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||
`quality/bookmarks.md` and tick it; no `todo!()` left.
|
||||
- [x] **T7 — live smoke test.** Capture a real Tidal artist subtree into a
|
||||
temp store through the provider layer, browse it through a
|
||||
`/bookmarks` instance, rename it, resolve an album, and fetch a stream
|
||||
URL for one captured link — remove any temporary probe afterwards.
|
||||
Verifies: end-to-end D1/D2/D4 behavior outside unit scope.
|
||||
- [x] **T8 — docs.** `plan/summary.md` section incl. deviations; reconcile
|
||||
`architecture/queue-persistence.md` D8 (saved queues now
|
||||
renamable/deletable) and `architecture/bookmarks.md` if the
|
||||
implementation diverged.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Plan: captures
|
||||
|
||||
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs`,
|
||||
`crabidy-server/src/capture.rs`, `crabidy-server/src/capture_store.rs`
|
||||
and/or gates in `quality/captures.md`). Stubs, the proto field, tidal's
|
||||
flag rule, and the full orchestrator/TUI plumbing exist; the new tests
|
||||
fail on `todo!()` at plan time.
|
||||
|
||||
- [x] **T1 — fsdy file-playable constructor.**
|
||||
`TrackFile::from_track_with_file`: metadata like `from_track`, playable
|
||||
`file = <relative sibling>`. Verifies:
|
||||
`from_track_with_file_plays_the_relative_sibling`.
|
||||
- [x] **T2 — capture.rs naming + extension helpers.** `audio_file_name`
|
||||
(shared `ordered_name` semantics via a public fsdy seam) and
|
||||
`extension_for` (Content-Type map, URL-path fallback, `bin`).
|
||||
Verifies: `extension_prefers_content_type_then_url_then_bin`,
|
||||
`audio_files_pair_with_their_toml_names`.
|
||||
- [x] **T3 — shared walk.** Move the bookmark walk into
|
||||
`capture::capture_into`/`write_tree` parameterized by `Caps` and
|
||||
`Sink`; per-track writes go through `write_track` (`Sink::Link` =
|
||||
today's link file). Rewire `BookmarkStore` onto it (delete its copy;
|
||||
keep its public API and `BOOKMARK_CAPS`-equivalent behavior).
|
||||
Verifies: all existing `bookmark_store::tests` unchanged; gates
|
||||
"Shared walk".
|
||||
- [x] **T4 — download sink.** `Downloader::new` (connect timeout),
|
||||
`Sink::Download` in `write_track`: per-track deadline around URL
|
||||
fetch, GET, and the streamed body against `bytes_left`,
|
||||
`error_for_status`, extension from the response, audio first then toml
|
||||
(`from_track_with_file`). Verifies:
|
||||
`download_capture_writes_audio_next_to_pointing_tomls`,
|
||||
`download_capture_is_all_or_nothing`,
|
||||
`download_capture_enforces_its_caps`; gates "Download sink".
|
||||
- [x] **T5 — CaptureStore.** `open` (create dir + build downloader),
|
||||
`capture` → blessing check (`source_allows_download`: node or track
|
||||
parent) then `capture_with_caps(DOWNLOAD_CAPS)` → `capture_into`.
|
||||
Verifies: `capture_requires_the_root_download_blessing`,
|
||||
`capturing_a_single_blessed_track_writes_one_pair`,
|
||||
`capture_validates_names_and_overwrites`; gates "Opt-in".
|
||||
- [x] **T6 — TUI tests.** Binding test for `W` (Library scope, shift),
|
||||
`selected_downloadable` gating (queueable-but-not-downloadable stays
|
||||
closed; tracks inherit the node flag), overlay label `capture`,
|
||||
submit carries `download: true`. Verifies: new `cbd-tui` tests; gates
|
||||
"TUI".
|
||||
- [x] **T7 — full verification.** Whole workspace suite green;
|
||||
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||
`quality/captures.md` and tick it; no `todo!()` left.
|
||||
- [x] **T8 — live smoke test.** Capture a real Tidal track with download
|
||||
through the provider layer into a temp store (a single track keeps the
|
||||
probe cheap; the multi-track walk is unit-covered), verify the audio
|
||||
exists with a plausible size, replay through a `/captures` instance,
|
||||
and play-resolve it — remove any temporary probe afterwards.
|
||||
- [x] **T9 — docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile `architecture/captures.md` (tidal flag rule, anything else
|
||||
that moved).
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Plan: cbd-bundle
|
||||
|
||||
Ordered tasks; each names its verification (tests in `cbd/src/main.rs`
|
||||
and/or gates in `quality/cbd-bundle.md`).
|
||||
|
||||
- [x] **T1 — Server library extraction.** Move `playback`/`provider`/
|
||||
`rpc` and the command/message enums into the `crabidy-server` lib;
|
||||
add `serve(addr)` + `LISTEN_ADDR`; thin `main.rs`. Verifies: the
|
||||
moved server suite passes unchanged; gates "Refactor".
|
||||
- [x] **T2 — TUI library extraction.** `cbd-tui/src/lib.rs` with
|
||||
`run(config)` (orchestrate + run_ui + channels); thin `main.rs` keeps
|
||||
file tracing + config. Verifies: the moved TUI suite passes
|
||||
unchanged.
|
||||
- [x] **T3 — cbd crate.** Workspace member; main = shared file tracing,
|
||||
`cbd-tui.toml` config, spawn `serve(LISTEN_ADDR)`, `wait_for_server`
|
||||
(scheme-stripped TCP poll, adopt-on-occupied-port, dead-server
|
||||
error, bounded attempts), then `cbd_tui::run`. Verifies:
|
||||
`readiness_polls_until_the_socket_accepts`,
|
||||
`readiness_gives_up_and_reports_a_dead_server`; gates "Bundled
|
||||
behavior".
|
||||
- [x] **T4 — Full verification.** Workspace suite green; clippy/fmt/
|
||||
taplo/markdownlint clean.
|
||||
- [x] **T5 — Live smoke test.** Boot the extracted `serve()` on a free
|
||||
port through `wait_for_server` (temporary ignored probe, removed
|
||||
after passing): the real stack — tidal login, providers, playback,
|
||||
queue restore — accepted a TCP connection in ~1 s.
|
||||
- [x] **T6 — Docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile `architecture/cbd-bundle.md`.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Plan: fs-provider
|
||||
|
||||
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs`
|
||||
and/or gates in `quality/fs-provider.md`). Stubs, tests, and workspace
|
||||
wiring exist; all 13 fsdy tests fail on `todo!()` at plan time.
|
||||
|
||||
- [x] **T1 — `TrackFile::parse` + `playable()` validation.** TOML parse
|
||||
into the schema structs; `playable()` enforces exactly-one, http(s)
|
||||
scheme, absolute link, no `/fs` link. Verifies:
|
||||
`parse_accepts_a_full_track_file`, `parse_defaults_optional_metadata`,
|
||||
`parse_rejects_wrong_playable_cardinality`,
|
||||
`parse_rejects_invalid_playables`; gate "link chains impossible".
|
||||
- [x] **T2 — `TrackFile::to_track`.** Single conversion site; link rewrites
|
||||
the path, file/url keep it; album/duration mapped. Verifies:
|
||||
`to_track_rewrites_the_path_only_for_links`; gate "single
|
||||
file-to-Track conversion".
|
||||
- [x] **T3 — path scheme: `is_track_path` + `disk_path`.** Suffix+prefix
|
||||
check; decode-validate-join with `.`/`..`/empty/separator rejection in
|
||||
one helper. Verifies: `track_paths_need_the_suffix_and_the_provider_prefix`,
|
||||
`client_paths_cannot_escape_the_root`; gate "one validation place".
|
||||
- [x] **T4 — `init` + `settings`.** Parse `Settings`, default root
|
||||
`dirs::audio_dir()`, `Config` error when neither; nonexistent root
|
||||
accepted; write-back serialization. Verifies:
|
||||
`settings_round_trip_through_the_config_write_back`,
|
||||
`init_accepts_a_root_that_does_not_exist_yet`.
|
||||
- [x] **T5 — `read_track_file` + `get_metadata_for_track` +
|
||||
`get_urls_for_track`.** tokio::fs read, parse, playable dispatch
|
||||
(relative file joined onto the track file's dir; link →
|
||||
`MalformedPath` warn). Verifies: `urls_resolve_per_playable_kind`,
|
||||
`metadata_of_a_link_track_carries_the_target_path`; gates "plain
|
||||
paths/URLs only", "no file contents in logs".
|
||||
- [x] **T6 — `list_dir` + `get_lib_node` + `get_lib_root`.** Sorted
|
||||
case-insensitive listing; skip symlinks/hidden/non-UTF-8/foreign/
|
||||
broken (warn with file name); encoded child paths; parent links.
|
||||
Verifies: `listing_sorts_and_skips_foreign_hidden_and_broken_entries`,
|
||||
`listing_a_missing_directory_is_an_error_not_a_panic`,
|
||||
`nodes_link_back_to_their_parent`,
|
||||
`resolving_a_tree_streams_chunks_in_listing_order`; gates "symlinks
|
||||
skipped", "listing order = resolve order", "tokio::fs only".
|
||||
- [x] **T7 — orchestrator wiring.** `fs_client: Option<Arc<fsdy::Client>>`
|
||||
in `ProviderOrchestrator`; non-fatal init from `fsdy.toml` with
|
||||
write-back; `/fs` arms in every trait method; root child only when
|
||||
present; `crabidy-server/Cargo.toml` gains `fsdy`. Verifies: gates
|
||||
under "Orchestrator wiring"; workspace build.
|
||||
- [x] **T8 — full verification.** All fsdy tests + whole workspace suite
|
||||
green; clippy/fmt/taplo/markdownlint clean; walk the remaining gates
|
||||
and tick them; no `todo!()` left.
|
||||
- [x] **T9 — live smoke test.** Build a real tree under a temp root
|
||||
(nested dirs, a relative-file track, a url track, a `/tidal` link
|
||||
track), run the server pointing at it, list `/fs` over the provider
|
||||
layer, and resolve a directory — remove any temporary probe
|
||||
afterwards. Verifies: end-to-end behavior of D2/D4 outside unit
|
||||
scope.
|
||||
- [x] **T10 — docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile `architecture/fs-provider.md` if the implementation
|
||||
diverged.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Plan — help modal (cbd-tui)
|
||||
|
||||
Ordered tasks for the `implement` stage. Inputs: `architecture/help-modal.md`,
|
||||
stubs in `cbd-tui/src/app/{bindings,help,mod}.rs`, gates in
|
||||
`quality/help-modal.md`. Run tests with `devenv shell -- cargo test -p cbd-tui`.
|
||||
|
||||
## 1. Binding lookup
|
||||
|
||||
- [x] Implement `bindings::lookup`: scan `BINDINGS` in order; when `help_open`
|
||||
only `Scope::Help` entries are eligible, otherwise `Scope::Global` plus
|
||||
the scope matching `focus`. Compare chords with `SHIFT` ignored for
|
||||
`KeyCode::Char` codes, exact modifiers otherwise. Return the first match.
|
||||
**Verify**: all `app::bindings::tests::*lookup*`-related tests pass
|
||||
(`global_bindings_match_in_any_focus`,
|
||||
`pane_bindings_only_match_their_own_pane`, `same_chord_resolves_per_pane`,
|
||||
`shift_is_ignored_for_char_codes`, `control_must_match_exactly`,
|
||||
`open_help_swallows_everything_but_close`,
|
||||
`help_scope_never_matches_while_help_is_closed`).
|
||||
- [x] Implement `bindings::key_label`: `Char(' ')` → `"Space"`, other chars →
|
||||
the char itself, `Tab`/`Enter`/`Esc` named, `CONTROL` prefix `"Ctrl+"`;
|
||||
no panic on any input (fall back to `Debug`-ish formatting for unbound
|
||||
codes). **Verify**: `key_labels_are_human_readable` passes; gate "no
|
||||
panics on user input".
|
||||
|
||||
## 2. Action dispatch
|
||||
|
||||
- [x] Implement `App::dispatch` as one `match action` reproducing, arm for
|
||||
arm, the behavior of the old key match in `main.rs` (send via `self.tx`
|
||||
with `let _ =`, or call `self.library`/`self.queue` methods).
|
||||
`OpenHelp`/`CloseHelp` set `show_help`; `Quit` returns
|
||||
`DispatchResult::Quit`; everything else `Continue`.
|
||||
**Verify**: all `app::tests::*` pass; gate "behavior parity" (cross-check
|
||||
against the pre-change `main.rs` match arm by arm).
|
||||
|
||||
## 3. Wire the event loop
|
||||
|
||||
- [x] Replace the key match in `main.rs run_ui` with: on key press,
|
||||
`bindings::lookup(app.focus, app.show_help, key)` then
|
||||
`app.dispatch(action)`, breaking the loop on `DispatchResult::Quit`.
|
||||
Keep the `KeyEventKind::Press` filter. Remove the now-dead imports
|
||||
(`KeyCode`, `KeyModifiers`, `UiFocus`, `MessageFromUi` uses that move
|
||||
into `dispatch`). **Verify**: `cargo check` clean, no key handling left
|
||||
in `main.rs` (gate "single source of truth"); manual smoke via `?`.
|
||||
|
||||
## 4. Help modal rendering
|
||||
|
||||
- [x] Implement `help::popup_area`: content-sized centered `Rect` clamped to
|
||||
the frame. **Verify**: `popup_never_exceeds_the_frame`.
|
||||
- [x] Implement `help::render`: `Clear` the popup area, draw a bordered block
|
||||
(style matching the panes: rounded borders, `COLOR_PRIMARY`), a short
|
||||
usage paragraph naming the Library and Queue panes and `Tab` to switch,
|
||||
then `BINDINGS` grouped by scope in table order with labels from
|
||||
`key_label`. Truncate lines that don't fit; never panic on tiny frames.
|
||||
**Verify**: `help_lists_bindings_from_the_table`,
|
||||
`help_explains_basic_usage`, `help_survives_tiny_terminals` pass; gates
|
||||
"modality" (overlay drawn last, `Clear` used) and "no panics".
|
||||
|
||||
## 5. Polish and gates
|
||||
|
||||
- [x] Resolve the `TODO(api-design)` on `LibraryQueueNext`: check what
|
||||
`MessageFromUi::QueueTracks` does in `crabidy-server` and fix the
|
||||
description text if needed. **Verify**: gate "TODO resolved".
|
||||
- [x] Sweep: doc comments still accurate, no dead-code warnings left for
|
||||
`bindings`/`help`, no new dependencies.
|
||||
**Verify**: `devenv shell -- cargo fmt --check`,
|
||||
`devenv shell -- cargo clippy -p cbd-tui` (no new warnings),
|
||||
`devenv shell -- cargo test -p cbd-tui` all green; every box in
|
||||
`quality/help-modal.md` checked.
|
||||
- [x] Write `plan/summary.md` (or a `help-modal` section in it) recording any
|
||||
deviations from this plan.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Plan: incremental-captures
|
||||
|
||||
Ordered tasks; each names its verification (tests and/or gates in
|
||||
`quality/incremental-captures.md`).
|
||||
|
||||
- [x] **T1 — Skipped playable in fsdy + proto.** `PlayableSpec.skipped`,
|
||||
`Playable::Skipped`, cardinality validation, `from_track_skipped`,
|
||||
`from_track` preserves skipped-ness, `to_track` sets `is_skipped`;
|
||||
`Track.is_skipped = 6` in the proto; fix all struct literals. Verifies:
|
||||
fsdy parse/round-trip tests; gates "Skipped playable".
|
||||
- [x] **T2 — Two-phase walk + incremental download sink.** Enumerate
|
||||
(caps) then fetch; `Sink::Download` writes into the final folder,
|
||||
reuses satisfied entries, writes skipped tomls for uncapturable tracks,
|
||||
keeps progress on abort; `Sink::Link` keeps tmp-and-swap. Progress
|
||||
callback plumbed through. Verifies: capture/capture_store tests
|
||||
(resume, reuse, skipped toml, abort-keeps-progress, caps); gates
|
||||
"Incremental capture".
|
||||
- [x] **T3 — Proto `CaptureProgress` + accept-then-stream RPC.** New
|
||||
oneof update; provider validates then replies and spawns the walk with
|
||||
a bounded progress channel; rpc forwards progress into the update
|
||||
broadcast. Verifies: gates "Progress + RPC".
|
||||
- [x] **T4 — Playback skip.** Flag-based skip without provider call;
|
||||
skip loop bounded by queue length. Verifies: playback tests; gate
|
||||
"Playback".
|
||||
- [x] **T5 — TUI.** Red skipped tracks (queue + library), capture
|
||||
progress lines with expiry, warnings in help + input label, focused
|
||||
selection contrast fix. Verifies: cbd-tui render/dispatch tests; gates
|
||||
"TUI".
|
||||
- [x] **T6 — Full verification.** Workspace suite green; clippy/fmt/
|
||||
taplo/markdownlint clean; quality gates ticked.
|
||||
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile `architecture/incremental-captures.md`.
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# Plan — editable and deletable nodes
|
||||
|
||||
Ordered tasks for the `implement` stage. Inputs: `architecture/node-editing.md`,
|
||||
stubs across the workspace, gates in `quality/node-editing.md`. Tests:
|
||||
`devenv shell -- cargo test --workspace` (10 failing at plan time = the
|
||||
target: 5 in tidaldy, 5 in cbd-tui). Use the session-local `CARGO_TARGET_DIR`
|
||||
if `target/` contains artifacts owned by the repo owner.
|
||||
|
||||
## 1. Term-list mutations (tidaldy)
|
||||
|
||||
- [x] Implement `rename_search_term(old, new)`: under one write lock, if
|
||||
`new` already exists remove the `old` slot (merge); else if `old`
|
||||
exists replace it in place; else push `new` (stale-client
|
||||
forgiveness). Implement `remove_search_term(term)`: `retain`
|
||||
everything but `term`. Both poison-tolerant like
|
||||
`register_search_term`, no await under the lock. **Verify**:
|
||||
`rename_replaces_in_place_and_merges_duplicates` passes; gate "lock
|
||||
discipline".
|
||||
|
||||
## 2. Provider rename/delete (tidaldy)
|
||||
|
||||
- [x] `rename_lib_node`: trim new title → `InvalidInput` if empty;
|
||||
`parse_path(path)?` must be `TidalPath::SearchTerm(old)` else
|
||||
`NotSupported`; decode old term, `rename_search_term`, return
|
||||
`get_lib_node(join_path(parent, encode_segment(new)))`. **Verify**:
|
||||
`rename_rejects_empty_titles_and_foreign_paths` passes (validation
|
||||
before any network call).
|
||||
- [x] `delete_lib_node`: `parse_path(path)?` must be `SearchTerm` else
|
||||
`NotSupported`; decode + `remove_search_term` (idempotent); return
|
||||
`get_lib_node(/tidal/search)`. **Verify**: `delete_rejects_foreign_paths`
|
||||
and `delete_removes_terms_idempotently_and_returns_the_parent` pass.
|
||||
- [x] Flag term children in the `Search` arm of `get_lib_node`:
|
||||
`is_editable: true, is_deletable: true` via struct update. **Verify**:
|
||||
`search_node_lists_created_terms` passes (extended assertion).
|
||||
|
||||
## 3. Server handlers (crabidy-server)
|
||||
|
||||
- [x] Replace the `todo!()` bodies of `rpc.rs
|
||||
rename_library_node`/`delete_library_node`: record span fields,
|
||||
bounded(1) rendezvous with the matching `ProviderCommand`, map errors
|
||||
exactly like `create_library_node` (`NotSupported` →
|
||||
`failed_precondition` with an operation-specific message,
|
||||
`InvalidInput` → `invalid_argument`, rest → `internal`). **Verify**:
|
||||
gates "error mapping" + "bounded rendezvous"; `cargo check`.
|
||||
(Orchestrator routing and the command-loop arms were finalized at
|
||||
stub time — re-read them against the gate rather than re-doing them.)
|
||||
|
||||
## 4. TUI library accessors + marker (cbd-tui)
|
||||
|
||||
- [x] Implement `Library::selected_editable` / `selected_deletable` from
|
||||
`list_state.selected()` + the `UiItem` flags. **Verify**:
|
||||
`edit_opens_the_overlay_prefilled_only_on_editable_selections`,
|
||||
`delete_sends_only_for_deletable_selections`,
|
||||
`rename_submit_sends_the_trimmed_new_title`,
|
||||
`rename_cancels_and_emptied_buffers_send_nothing` pass (dispatch and
|
||||
overlay logic already landed with the stubs).
|
||||
- [x] Render the `[e]`/`[d]`/`[ed]` suffix for modifiable items in
|
||||
`Library::render`, `COLOR_SECONDARY` like `[%]`. **Verify**:
|
||||
`modifiable_items_are_marked_and_the_rename_overlay_is_labelled`
|
||||
passes; gate "marked in UI".
|
||||
|
||||
## 5. TUI ↔ server wiring (cbd-tui)
|
||||
|
||||
- [x] `RpcClient::rename_library_node`: send the rpc, evict the old path and
|
||||
the parent (`crabidy_core::parent_path`), insert + return the renamed
|
||||
node. `delete_library_node`: send the rpc, evict the deleted path and
|
||||
the parent, insert + return the returned parent node. **Verify**: gate
|
||||
"cache eviction" (read; the cache is private, as with create).
|
||||
- [x] `main.rs poll`: replace the `todo!()` arms — `RenameNode` calls
|
||||
`rename_library_node`, `DeleteNode` calls `delete_library_node`; on
|
||||
success `ReplaceLibraryNode(node)`, on failure `error!` and leave the
|
||||
UI unchanged. **Verify**: gate "failures never panic".
|
||||
|
||||
## 6. End-to-end + gates sweep
|
||||
|
||||
- [x] Exercise the full path against the live API if the local tidal config
|
||||
is available (create a term, rename it, delete it, confirm the parent
|
||||
listing and that a queued search track still resolves a URL); note the
|
||||
outcome in the summary. If unavailable, rely on the offline tests and
|
||||
say so.
|
||||
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests green,
|
||||
every `quality/node-editing.md` box checked, docs updated where
|
||||
behavior shifted. Append the outcome + deviations to `plan/summary.md`.
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
# Plan — progressive queueing
|
||||
|
||||
Ordered tasks for the `implement` stage. Inputs:
|
||||
`architecture/progressive-queueing.md`, the workspace stubs, gates in
|
||||
`quality/progressive-queueing.md`. Tests:
|
||||
`devenv shell -- cargo test --workspace` (13 failing at plan time = the
|
||||
target: 5 in crabidy-core, 5 in crabidy-server, 2 in cbd-tui, 1 in tidaldy).
|
||||
Use the session-local `CARGO_TARGET_DIR` if `target/` contains artifacts
|
||||
owned by the repo owner.
|
||||
|
||||
## 1. Default chunked resolve (crabidy-core)
|
||||
|
||||
- [x] Implement the `resolve_tracks_into` default body: track path →
|
||||
single chunk from `get_metadata_for_track` (failure → warn, `Ok`, no
|
||||
chunk — matches today's skip); node path → depth-first **pre-order**
|
||||
walk (stack of paths, children pushed in reverse so siblings pop in
|
||||
listing order), per node: skip unreadable with a warning (root
|
||||
failure → `Err`), skip non-queueable, send `tracks` as one chunk when
|
||||
non-empty; a failed send (receiver gone) → return `Ok` immediately.
|
||||
**Verify**: all 5 `default_resolve_*` tests pass.
|
||||
|
||||
## 2. PendingResolve state machine (crabidy-server lib)
|
||||
|
||||
- [x] Implement `new`/`cancel_flag`/`cancel`/`applied`/`apply_chunk`.
|
||||
`apply_chunk`: `Replace` → `replace_with_tracks` for the first chunk
|
||||
(returns current track), then mutate kind to `Append`; `Append` →
|
||||
`append_tracks`; `InsertAfter(pos)` → `insert_tracks(pos)` then
|
||||
advance the cursor by `tracks.len()` (clamping is `insert_tracks`'
|
||||
job); count `applied`. **Verify**: the 5 op tests pass
|
||||
(`replace_op_*`, `insert_after_op_*` ×2, `append_op_*`,
|
||||
`cancel_flag_*`).
|
||||
|
||||
## 3. Playback loop wiring (crabidy-server)
|
||||
|
||||
- [x] Implement `broadcast_queue` (snapshot → `Queue`, set `resolving`
|
||||
from the pending map, broadcast) and route the remaining direct
|
||||
`StreamUpdate::Queue(...)` broadcasts in `handle_command` through it.
|
||||
**Verify**: gate "resolving is true iff at least one op is pending".
|
||||
- [x] Implement `start_resolve`: allocate op id, insert `PendingResolve`,
|
||||
immediate `broadcast_queue`, spawn the forwarder
|
||||
(`tokio::spawn` + `in_current_span`): per path in order — check the
|
||||
cancel flag (set → stop), open `flume::bounded(4)` chunk channel,
|
||||
send `ProviderCommand::ResolveTracks`, forward each chunk as
|
||||
`ApplyResolvedChunk` (checking the cancel flag between chunks, drop
|
||||
the receiver on cancel); after all paths (or on any exit path) send
|
||||
`ResolveFinished`. Track the op's paths for the zero-track warning.
|
||||
**Verify**: gates "immediate feedback", "cancellation propagates",
|
||||
"no panics"; `cargo check`.
|
||||
- [x] Implement `apply_resolved_chunk` (look up op — unknown id: drop
|
||||
silently; lock queue, `apply_chunk`, `broadcast_queue`, then
|
||||
`play_if_some` outside the locks), `finish_resolve` (remove op, warn
|
||||
when `applied() == 0` with the op's paths, `broadcast_queue`), and
|
||||
`cancel_pending_resolves` (cancel + clear map; no broadcast needed —
|
||||
the caller mutates and broadcasts next). **Verify**: gates "playback
|
||||
starts with the first chunk", "late chunks dropped"; existing
|
||||
playback tests still green.
|
||||
|
||||
## 4. tidaldy page-streamed resolve
|
||||
|
||||
- [x] Implement `make_paginated_request_into` (same loop as
|
||||
`make_paginated_request`, `sink(page.items)` per page, stop on
|
||||
`false`). **Verify**: gate "token/refresh path reused"; `cargo check`.
|
||||
- [x] Implement the `resolve_tracks_into` override: `parse_path` first
|
||||
(foreign → `MalformedPath` before any I/O); track paths → one
|
||||
metadata chunk; pre-order worklist walk where `Playlist` and `Album`
|
||||
nodes stream `tracks/items` pages as chunks (`Track::to_proto` per
|
||||
page, no playlist-metadata fetch) and all other node kinds fall back
|
||||
to `get_lib_node` (tracks as one chunk when queueable, children onto
|
||||
the worklist in listing order). Failed sends → stop, `Ok`.
|
||||
**Verify**: `resolve_rejects_foreign_paths_before_any_network_call`
|
||||
passes; gate "order preserved end-to-end".
|
||||
|
||||
## 5. TUI indicator (cbd-tui)
|
||||
|
||||
- [x] Implement `Queue::loading_dots` (1 + (elapsed_ms / 400) % 3 dots)
|
||||
and render the pseudo-item: when `resolving`, push one extra
|
||||
`ListItem` (dots, `COLOR_SECONDARY`) after the track rows, computed
|
||||
from a monotonic clock at render time; `self.list` stays untouched.
|
||||
**Verify**: `loading_dots_cycle_one_to_three`,
|
||||
`resolving_queue_renders_trailing_dots_item` pass; the three guard
|
||||
tests stay green.
|
||||
|
||||
## 6. End-to-end + gates sweep
|
||||
|
||||
- [x] Exercise the full path against the live API if the local tidal
|
||||
config is available (temporary ignored probe: resolve a multi-album
|
||||
artist through the orchestrator, assert multiple chunks arrive, in
|
||||
listing order, first chunk before the walk completes; drop the
|
||||
receiver mid-stream and confirm fetching stops). Note the outcome in
|
||||
the summary; if unavailable, rely on the offline tests and say so.
|
||||
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests
|
||||
green, every `quality/progressive-queueing.md` box checked, docs
|
||||
updated where behavior shifted. Append the outcome + deviations to
|
||||
`plan/summary.md`.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Plan: queue-persistence
|
||||
|
||||
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs` /
|
||||
`crabidy-server/src/queue_store.rs` and/or gates in
|
||||
`quality/queue-persistence.md`). Stubs and workspace wiring exist; the new
|
||||
fsdy/queue-store tests fail on `todo!()` at plan time.
|
||||
|
||||
- [x] **T1 — fsdy instance parameterization.** `Client::new(provider_root,
|
||||
disk_root)` with validation; replace every hardcoded `"/fs"`/`"/fs/"`
|
||||
inside `Client` methods with instance state (`disk_path`,
|
||||
`is_track_path`, `list_dir` title/paths, `get_lib_root`); `init` builds
|
||||
the `/fs` instance via `new`. Verifies:
|
||||
`instances_serve_their_own_provider_root`,
|
||||
`instance_paths_cannot_escape_their_root_either`,
|
||||
`new_rejects_malformed_roots`; gates "fsdy instances" (prefix
|
||||
derivation, single traversal site).
|
||||
- [x] **T2 — fsdy serialization.** `TrackFile::from_track` (uniform link
|
||||
playable), `to_toml`, `track_file_name` (zero-padded prefix +
|
||||
sanitized title). Verifies: `from_track_round_trips_through_a_link_file`,
|
||||
`from_track_serializes_sparse_metadata`,
|
||||
`track_file_names_sort_in_queue_order_and_stay_plain`,
|
||||
`links_into_fs_instances_are_legal_and_one_hop` (rule relaxation, done
|
||||
at stub time).
|
||||
- [x] **T3 — QueueStore.** `open` (create_dir_all), `validate_name`,
|
||||
`save`/`persist_current` via one tmp-and-swap writer (entries +
|
||||
`.queue-state.toml` sidecar), `load_current` (sorted listing, skip
|
||||
broken, default state on sidecar defects). Verifies: all
|
||||
`queue_store::tests` except `persister_writes_the_latest_snapshot`;
|
||||
gates "Queue store".
|
||||
- [x] **T4 — persister task.** `spawn_persister`: watch changes →
|
||||
debounce → skip-if-equal → `persist_current`, warn on failure, exit on
|
||||
sender drop. Verifies: `persister_writes_the_latest_snapshot`; gates
|
||||
"playback wiring" (debounce/skip, warnings only).
|
||||
- [x] **T5 — playback loop wiring.** `Playback::new` takes
|
||||
`Option<Arc<QueueStore>>`; watch sender + persist sends from
|
||||
`broadcast_queue`, `play`, and the shuffle/repeat toggles; `run`
|
||||
spawns the persister; `PlaybackCommand::SaveQueue { name, result_tx }`
|
||||
handled on the loop (snapshot, spawn write, reply);
|
||||
`restore_current` applies tracks + position + mods before `run`, no
|
||||
autoplay. Playback-level tests (restore → Init snapshot; SaveQueue
|
||||
ok/empty; a queue mutation reaches the store). Verifies: new
|
||||
`playback::tests`; gates "Playback wiring".
|
||||
- [x] **T6 — orchestrator + server startup.** `queues_client:
|
||||
Option<Arc<fsdy::Client>>` mounted over `QueueStore::dir` at
|
||||
`/queues`; routing arms in every trait method; root child when
|
||||
present; `main.rs` builds the store (non-fatal), restores, spawns
|
||||
everything in order. Verifies: gates "RPC and orchestrator";
|
||||
workspace build.
|
||||
- [x] **T7 — rpc `save_queue`.** Send `SaveQueue` to the playback loop,
|
||||
map `SaveQueueError` → `Status` (invalid_argument /
|
||||
failed_precondition / internal). Verifies: gate "RPC and
|
||||
orchestrator" (error mapping).
|
||||
- [x] **T8 — TUI save flow.** `Action::QueueSaveAs` (`w`, `Scope::Queue`),
|
||||
`InputPurpose::SaveQueue` (+ overlay label), `MessageFromUi::SaveQueue`,
|
||||
orchestrator arm → `rpc::save_queue`. TUI tests: binding lookup,
|
||||
overlay open only with a non-empty queue, submit sends the trimmed
|
||||
name. Verifies: new `cbd-tui` tests; gates "TUI".
|
||||
- [x] **T9 — full verification.** Whole workspace suite green;
|
||||
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||
`quality/queue-persistence.md` and tick it; no `todo!()` left.
|
||||
- [x] **T10 — live smoke test.** Temp store + real tree: persist a mixed
|
||||
queue (tidal + fs tracks), reload it, browse `/queues` through the
|
||||
provider instance, queue a saved folder via the resolve walk — remove
|
||||
any temporary probe afterwards. Verifies: end-to-end D2/D5 behavior
|
||||
outside unit scope.
|
||||
- [x] **T11 — docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile `architecture/fs-provider.md` (one-hop links) and
|
||||
`architecture/queue-persistence.md` if the implementation diverged.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Plan — roles and rights
|
||||
|
||||
From `architecture/roles-auth.md` and `quality/roles-auth.md`.
|
||||
|
||||
- [x] **Deps**: workspace `argon2` (with `std`), `http`, `tower`;
|
||||
`crabidy-server` gains `argon2`, `base64`, `http`, `tower`,
|
||||
`clap`; `cbd-tui` gains `base64`. Verify: workspace builds.
|
||||
- [x] **Server settings** (`crabidy-server/src/settings.rs`):
|
||||
`ServerSettings { auth: AuthSettings }`,
|
||||
`AuthSettings { owner, queue_owner, queue_appender: Option<String> }`;
|
||||
`load(config_dir)` — absent file ⇒ defaults, malformed file ⇒
|
||||
startup error. Verify: unit tests for all three cases.
|
||||
- [x] **Auth core** (`crabidy-server/src/auth.rs`): `Role` (ordered),
|
||||
`minimum_role(method) -> Role` default-deny table,
|
||||
`Authenticator` (parse Basic header, argon2 verify, success
|
||||
cache, disabled mode). Verify: unit tests — header parsing
|
||||
never panics, wrong user/password indistinguishable, matrix
|
||||
samples per role, unknown method ⇒ owner, cache fed only by
|
||||
successes, disabled mode allows all without header.
|
||||
- [x] **Tower layer** (`auth.rs`): `AuthLayer`/`AuthService` checking
|
||||
`authorization` against the method's minimum role before the
|
||||
inner service; denials answer trailers-only via
|
||||
`Status::into_http()`. Verify: service-level tests with a
|
||||
counting inner service (deny short-circuits, allow forwards,
|
||||
unauthenticated vs permission-denied codes).
|
||||
- [x] **Wire-up** (`lib.rs::serve`): load settings, fail startup on
|
||||
malformed config, install the layer. Verify: existing tests
|
||||
still pass; layer test covers enforcement.
|
||||
- [x] **hash-password** (`main.rs` + clap): subcommand reads stdin,
|
||||
prints PHC string; round-trip test hash → authenticator accepts.
|
||||
- [x] **Client** (`cbd-tui`): `user`/`password` config options (and
|
||||
flags), auth interceptor attaching a precomputed Basic header to
|
||||
every request, type alias for the intercepted client. Verify:
|
||||
unit tests — no creds ⇒ no header, creds ⇒ header present.
|
||||
- [x] **Docs**: root README (config table row, `cbd-tui.toml` options,
|
||||
security note), new `crabidy-server.toml` section; architecture
|
||||
cross-links. Verify: markdownlint.
|
||||
- [x] **Gates**: run the full suite + clippy + fmt; check off
|
||||
`quality/roles-auth.md`; write `plan/summary.md` section.
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
# Plan — search via creatable nodes
|
||||
|
||||
Ordered tasks for the `implement` stage. Inputs: `architecture/search.md`,
|
||||
stubs across all five crates, gates in `quality/search.md`. Tests:
|
||||
`devenv shell -- cargo test -p crabidy-core -p tidaldy -p cbd-tui`
|
||||
(11 failing at plan time = the target). Note: use a session-local
|
||||
`CARGO_TARGET_DIR` if `target/` contains root-owned artifacts from the
|
||||
repo owner's builds.
|
||||
|
||||
## 1. Path segment encoding (crabidy-core)
|
||||
|
||||
- [x] Add the `percent-encoding` crate (workspace dependency; check the
|
||||
nixpkgs/devenv side is unaffected — pure Rust). Implement
|
||||
`encode_segment` (encode `/`, `%`, whitespace, controls and everything
|
||||
non-alphanumeric-unreserved via an `AsciiSet`) and `decode_segment`
|
||||
(lossy UTF-8 decode). **Verify**: the three `crabidy-core` tests pass
|
||||
(`encode_segment_round_trips_arbitrary_text`,
|
||||
`encoded_segments_are_path_safe`, `decode_segment_is_lossy_not_panicky`);
|
||||
gates "encode/decode are total", "only percent-encoding added".
|
||||
|
||||
## 2. Verify the search payload shape (tidaldy, live API)
|
||||
|
||||
- [x] Before typing the models: run the existing explorer helper
|
||||
(`Client::search`) once against the live API (needs the local tidal
|
||||
config; if unavailable, consult the response shapes used by other tdl
|
||||
clients and mark the gate as verified-by-proxy). Confirm whether
|
||||
`search/tracks|artists|albums` return the same `Page<T>` item shapes as
|
||||
the library endpoints; put any deviation into `tidaldy::models`.
|
||||
**Verify**: gate "models verified against the live API".
|
||||
|
||||
## 3. Typed search requests (tidaldy)
|
||||
|
||||
- [x] Implement `search_tracks/search_artists/search_albums`: single
|
||||
`make_request` with `query`, `limit=SEARCH_RESULT_LIMIT`, `offset=0`,
|
||||
decoding a `Page<T>`; no pagination loop. **Verify**: gate "first page
|
||||
only"; unit-testable only against live API (leave network test
|
||||
`#[ignore]` like the existing one).
|
||||
|
||||
## 4. Search subtree in the provider (tidaldy)
|
||||
|
||||
- [x] `is_track_path`: include `TidalPath::SearchTrack`; `track_id_from_path`
|
||||
already falls out (its match is on the parsed variant — extend it).
|
||||
**Verify**: `search_track_paths_are_track_paths`,
|
||||
`track_id_is_extracted_from_search_track_paths` pass.
|
||||
- [x] `get_lib_node` `Search` arm: children = one entry per stored term
|
||||
(path `join_path(path, encode_segment(term))`, title = raw term,
|
||||
`is_queable: false`), node `is_creatable: true`. Do not require
|
||||
`user_id` for search paths (move the `get_user_id` gate into the arms
|
||||
that need it — search must work even if the user id is missing).
|
||||
**Verify**: gate "search node lists terms"; ignored network test
|
||||
extended by hand.
|
||||
- [x] `get_lib_node` `SearchTerm` arm: decode the term; implicitly register
|
||||
unknown terms (stale-client recovery); fetch the three categories
|
||||
concurrently (`tokio::join!`); node = tracks from `search_tracks`
|
||||
(`to_proto(path)`), children = artists (`Artist: <name>` →
|
||||
`/tidal/artists/<id>`, queueable) then albums (`Album: <title>` →
|
||||
`/tidal/artists/<artist-id>/<album-id>`, queueable), `is_queable:
|
||||
false`, `is_creatable: false`. **Verify**: gates "term node" +
|
||||
"canonical children"; behavior exercised end-to-end in task 9.
|
||||
- [x] `create_lib_node`: trim title → `InvalidInput` if empty; parent must
|
||||
parse to `TidalPath::Search` else `NotSupported`; store raw term
|
||||
idempotently (no duplicates, lock not held across await); return
|
||||
`self.get_lib_node(term_path)`. **Verify**: gate "create semantics";
|
||||
add non-network unit tests: create with bad parent / empty title on an
|
||||
offline client returns the right errors (no API call happens before
|
||||
validation).
|
||||
|
||||
## 5. Server plumbing (crabidy-server)
|
||||
|
||||
- [x] `ProviderOrchestrator::create_lib_node`: `/tidal`-prefixed parent →
|
||||
`tidal_client.create_lib_node`, anything else `NotSupported` (warn like
|
||||
the other routes). **Verify**: gate "orchestrator routing".
|
||||
- [x] `rpc.rs create_library_node`: bounded(1) rendezvous with
|
||||
`ProviderCommand::CreateLibraryNode`; map errors `NotSupported` →
|
||||
`failed_precondition`, `InvalidInput` → `invalid_argument`, rest →
|
||||
`internal`. **Verify**: gate "error mapping"; `cargo check`.
|
||||
|
||||
## 6. TUI input overlay (cbd-tui)
|
||||
|
||||
- [x] `App::dispatch(LibraryCreateNode)`: open
|
||||
`InputState { parent_path: library.path(), buffer: "" }` only when
|
||||
`library.is_creatable()`. **Verify**:
|
||||
`create_node_only_opens_input_on_creatable_nodes`.
|
||||
- [x] `App::handle_input_key`: Esc cancels; Enter trims + sends
|
||||
`MessageFromUi::CreateNode` (empty → just close); Backspace pops;
|
||||
`KeyCode::Char(c)` appends regardless of SHIFT; all else ignored.
|
||||
**Verify**: `input_appends_and_backspace_pops`,
|
||||
`esc_cancels_without_sending`, `enter_submits_trimmed_title_and_closes`,
|
||||
`enter_on_empty_input_closes_without_sending`.
|
||||
- [x] Event loop (`main.rs run_ui`): when `app.input.is_some()`, route the
|
||||
key to `app.handle_input_key(key)` and skip `bindings::lookup`
|
||||
entirely. **Verify**: gate "input mode bypasses bindings" (read the
|
||||
loop; also confirm `q` cannot quit while typing).
|
||||
|
||||
## 7. TUI library rendering (cbd-tui)
|
||||
|
||||
- [x] `Library::update`: apply empty nodes when `node.is_creatable` (keep the
|
||||
skip for empty non-creatable nodes). **Verify**:
|
||||
`empty_creatable_nodes_are_enterable`.
|
||||
- [x] Mark creatable children in the list (suffix marker, `COLOR_SECONDARY`)
|
||||
— requires keeping `is_creatable` on `UiItem`; hint in the pane title
|
||||
when the open node is creatable (e.g. `search — % to add`).
|
||||
**Verify**: gate "creatable marked in UI" (read render code).
|
||||
- [x] Render the input overlay: bottom line of the library pane showing
|
||||
`new node: <buffer>▏` while `input.is_some()`. **Verify**: gate "input
|
||||
overlay renders"; add a `TestBackend` test asserting the buffer text
|
||||
appears while open (mirror the help-modal test helpers).
|
||||
|
||||
## 8. TUI ↔ server wiring (cbd-tui)
|
||||
|
||||
- [x] `RpcClient::create_library_node`: send request, evict
|
||||
`library_node_cache` entry for `parent_path`, insert the returned node,
|
||||
return it. **Verify**: gate "cache eviction" (read; the cache is
|
||||
private — no test seam without refactoring, keep it a gate).
|
||||
- [x] `main.rs poll`: `CreateNode` arm calls the client method; on success
|
||||
`ReplaceLibraryNode(node)`; on failure log and leave the UI unchanged
|
||||
(no panic — replace the stub's `todo!`). **Verify**: gate "create
|
||||
failures surface as errors, never a panic".
|
||||
|
||||
## 9. End-to-end + gates sweep
|
||||
|
||||
- [x] Run the real stack (`crabidy-server` + `cbd-tui`, needs tidal login):
|
||||
enter `/tidal/search`, `%`, type a term, Enter; results appear; queue a
|
||||
track result; dive into an artist result. If no login is available,
|
||||
exercise create/list/error paths against the offline client instead
|
||||
and note it in the summary. **Verify**: architecture flow diagram
|
||||
matches reality.
|
||||
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests green,
|
||||
every `quality/search.md` box checked, docs updated where behavior
|
||||
shifted. Append the outcome + deviations to `plan/summary.md`.
|
||||
|
|
@ -0,0 +1,806 @@
|
|||
# Implementation summaries
|
||||
|
||||
## capture visibility + log redaction (2026-07-21, follow-up)
|
||||
|
||||
"Problems with capturing" turned out to be a display bug: the capture
|
||||
had succeeded on disk, but the TUI's `RpcClient` caches every library
|
||||
listing for the whole session, so a `/captures` (or `/queues`,
|
||||
`/bookmarks`, `/fs`) listing visited once never showed later captures
|
||||
or saved queues until a restart. Listings under those mutable roots
|
||||
are now always refetched — they are cheap local directory walks on the
|
||||
server — while remote provider nodes (tidal, youtube) keep the cache
|
||||
that makes back-navigation instant (`is_cacheable`, unit-tested).
|
||||
|
||||
Found alongside in the same log: the player engine's `play` span
|
||||
recorded the full stream URL — googlevideo `sig` tokens included — into
|
||||
`cbd.log`. Sources are now logged as `scheme://host/…` only (local
|
||||
paths verbatim; `display_source`, unit-tested). 181 workspace tests
|
||||
green.
|
||||
|
||||
## youtube stream fetching (2026-07-21, follow-up)
|
||||
|
||||
The rustypipe swap fixed the decode problem but real playback then hit
|
||||
YouTube's tokenless-fetch enforcement, measured live: every stream URL
|
||||
serves exactly its leading 1 MiB (403 beyond — plain, open-ended, and
|
||||
oversized requests are rejected outright, and fresh URLs refuse offset
|
||||
starts, killing URL-per-window chaining). PO tokens would lift the cap,
|
||||
but rustypipe only attaches them to web clients whose signature
|
||||
deciphering is currently broken upstream (verified on git master;
|
||||
`rustypipe-botguard` built and tested — ineffective through the iOS
|
||||
client). `yt-dlp` still solves the ciphers; its URLs stream the whole
|
||||
file at a throttled ~32 KB/s — double the audio bitrate.
|
||||
|
||||
Shipped: (1) **windowed HTTP fetching** everywhere — a
|
||||
`WindowedHttpStream` `SourceStream` in audio-player (bounded ~1 MiB
|
||||
ranges, 200-body fallback for range-ignoring servers, eager
|
||||
seek/reconnect so rejected windows fail typed instead of retrying
|
||||
forever, URLs never in errors) and the same windowing in the capture
|
||||
downloader (strict-CDN test stitches windows byte-exact); (2) `yt-dlp`
|
||||
back as a **stream-URL-only sidecar** — all metadata stays on
|
||||
rustypipe; a missing binary degrades to 1 MiB streams with a warning,
|
||||
a failing call falls back to the rustypipe URL; (3) `botguard_bin`
|
||||
config passthrough so streams flip back to pure Rust when upstream
|
||||
deciphering recovers; (4) capture per-track deadline raised to 30 min
|
||||
for the throttle. Live-verified end to end on the exact track from the
|
||||
user's log: sidecar URL in 3 s, windowed stream + rodio decode
|
||||
producing samples at 4.3 s. 179 workspace tests green (12 new);
|
||||
`quality/youtube-rustypipe.md` gained a checked "Stream fetching"
|
||||
section.
|
||||
|
||||
## youtube-rustypipe (2026-07-21)
|
||||
|
||||
Built per `plan/youtube-rustypipe.md`: the ytdy provider's `yt-dlp`
|
||||
subprocess engine was replaced with the pure-Rust **rustypipe**
|
||||
Innertube client, fixing broken playback along the way.
|
||||
|
||||
Root cause of "search works but nothing plays": `-f bestaudio` selects
|
||||
WebM/**Opus**, and the player (rodio + symphonia) has no Opus decoder.
|
||||
The new engine picks the highest-bitrate `audio/mp4` (AAC) stream,
|
||||
which symphonia decodes — verified live end to end (rustypipe stream
|
||||
URL → download → `rodio::Decoder` produces samples). Download captures
|
||||
of YouTube tracks now get playable `.m4a` files too.
|
||||
|
||||
The alternatives the user suggested were live-tested first:
|
||||
`rusty_ytdl` 0.7.4 searches fine but returns empty stream URLs (cipher
|
||||
rotation outran it), `rustube` is unmaintained since ~2022,
|
||||
`rust-yt-downloader` is a thin CLI. `rustypipe` 0.11.4 worked for
|
||||
everything (see `architecture/youtube-rustypipe.md`).
|
||||
|
||||
Design: an `Extract` trait seam (search, video, audio stream URL,
|
||||
saved playlists, playlist videos) with `RustyPipeExtractor` as the
|
||||
real implementation — provider logic is tested against a programmable
|
||||
fake (no network, no fake shell scripts). Login keeps the `cookies`
|
||||
setting (Netscape export) via `user_auth_set_cookie_txt`, cache-first:
|
||||
rustypipe refreshes and persists the rotated cookie under
|
||||
`<config>/crabidy/rustypipe/`, so it outlives the stale export; any
|
||||
login failure degrades to logged-out. Saved playlists replace the
|
||||
never-validated `feed/playlists` scrape; playlist nodes page up to
|
||||
1000 tracks. `ytdy.toml` loses `binary` (old keys tolerated),
|
||||
`yt-dlp` left `devenv.nix`, ytdy no longer needs `tokio/process`.
|
||||
|
||||
Deviations: none from the new architecture doc; the original
|
||||
`youtube-provider.md` engine decision (D1) is marked superseded.
|
||||
Live probe (search → mp4 stream URL → 206 fetch → metadata) ran
|
||||
against real YouTube and was removed after passing. 169 workspace
|
||||
tests green (ytdy: 10 + 2 extractor tests, all offline); every gate in
|
||||
`quality/youtube-rustypipe.md` checked.
|
||||
|
||||
## incremental-captures (2026-07-21)
|
||||
|
||||
Built per `plan/incremental-captures.md`: download captures are now
|
||||
**incremental and resumable**, uncapturable tracks are first-class
|
||||
**skipped** entries, and captures stream **progress** to clients.
|
||||
|
||||
- **Skipped playable (fsdy + proto).** `[playable] skipped = true` is a
|
||||
fourth, mutually exclusive playable; `Playable::Skipped`,
|
||||
`from_track_skipped`, and a new wire flag `Track.is_skipped` (set by
|
||||
`to_track`). `from_track` preserves skipped-ness, so persisted queues
|
||||
and bookmarks keep the marking instead of degrading it into a dead
|
||||
link. `get_urls_for_track` on a skipped file is a typed `FetchError`.
|
||||
- **Incremental walk.** The shared capture walk now runs in two phases:
|
||||
enumerate (dirs + tracks, caps enforced — the total is known before
|
||||
the first download) then fetch. `Sink::Download` writes straight into
|
||||
`captures/<name>` (no tmp/swap): satisfied entries — parseable toml,
|
||||
non-skipped playable, audio present — are reused; skipped, broken, or
|
||||
audio-less entries are re-captured; uncapturable sources (skipped
|
||||
source track, unresolvable stream, non-http playable) are recorded as
|
||||
skipped tomls instead of silently omitted; a real download failure
|
||||
aborts the run but keeps everything written, so re-capturing the same
|
||||
name resumes. The byte budget counts only bytes downloaded per run.
|
||||
Bookmarks keep tmp-and-swap overwrite semantics unchanged.
|
||||
- **Progress + accept-then-stream RPC.** New `CaptureProgress` update on
|
||||
the stream (name, download, done/total/skipped, terminal
|
||||
finished/error). `CaptureLibraryNode` replies once validation (name,
|
||||
store, download blessing) passes; the walk runs detached and its
|
||||
bounded progress channel is forwarded into the update broadcast. This
|
||||
also unfreezes the TUI: its poll loop used to await the whole capture.
|
||||
- **Playback.** `play` skips `is_skipped` tracks without a provider round
|
||||
trip and bounds the whole skip loop to one full queue pass — an
|
||||
all-skipped queue with repeat on now stops instead of hammering the
|
||||
provider forever (pre-existing spin fixed).
|
||||
- **TUI.** Skipped tracks render red in queue and library (playing-track
|
||||
marker keeps precedence). A `CaptureBoard` renders progress lines at
|
||||
the bottom of the library pane (`capturing faves 3/12 (1 skipped)`),
|
||||
lingering 5 s on success and 10 s (red) on failure. The `W` help entry
|
||||
and the capture input label warn that captures are slow and resumable.
|
||||
Contrast fix: colored items (editable/marked/skipped/current) switch to
|
||||
the dark foreground under the focused selection bar.
|
||||
|
||||
Deviations from the architecture doc: `tracks_done` counts skipped
|
||||
entries too (the ratio must reach the total on success) — doc and proto
|
||||
reconciled; the input-overlay warning was shortened to
|
||||
"capture (slow, resumable)" to fit narrow panes. `taplo` reports a
|
||||
pre-existing formatting issue in `.opencode/skills/skill-authoring/`
|
||||
(not touched here). 167 workspace tests green (13 new);
|
||||
every gate in `quality/incremental-captures.md` checked.
|
||||
|
||||
## cbd-bundle (2026-07-21)
|
||||
|
||||
Built per `plan/cbd-bundle.md`: a new **`cbd`** binary bundles server
|
||||
and TUI. Both former binaries became libraries with thin mains —
|
||||
`crabidy_server::serve(addr)` is the extracted server startup
|
||||
(orchestrator, queue store, playback, player forwarder, tonic), and
|
||||
`cbd_tui::run(config)` the extracted client loops; the standalone
|
||||
binaries behave exactly as before. `cbd` sets up one file-based tracing
|
||||
subscriber for both halves (the terminal belongs to the TUI), spawns
|
||||
`serve` on the fixed listen address, polls a TCP connect against the
|
||||
TUI's configured server address until ready (bounded, generous — first
|
||||
runs may sit in a provider login), then runs the TUI. An
|
||||
already-running standalone server just gets adopted (the in-process
|
||||
bind fails on the occupied port and is deliberately ignored once the
|
||||
socket is reachable); a server that dies before readiness surfaces its
|
||||
real error. Quitting the TUI ends the process and the in-process
|
||||
server — the continuously persisted current queue makes that safe.
|
||||
|
||||
Deviations: none of substance — the refactor moved code verbatim
|
||||
(`crabidy_server::` → `crate::` path rewrites aside). The live probe
|
||||
booted the extracted stack on a free port through the same readiness
|
||||
poll `cbd` uses: real tidal login, all providers, playback, queue
|
||||
restore, TCP accept in ~1 s (probe removed after passing). 154
|
||||
workspace tests green (2 new in `cbd`); every gate in
|
||||
`quality/cbd-bundle.md` checked.
|
||||
|
||||
## captures follow-up: W on queues and bookmarks (2026-07-21)
|
||||
|
||||
Small fix on top of the captures feature: `/queues` and `/bookmarks`
|
||||
nodes are now `W`-capturable. `fsdy::Client` gained
|
||||
`with_downloadable_nodes()` (instance-wide `is_downloadable`, applied to
|
||||
the queues and bookmarks mounts; `/fs` and `/captures` stay off), and
|
||||
the download sink softens all-or-nothing for exactly one case: a track
|
||||
whose source cannot be captured — stream resolution fails, or resolves
|
||||
to a non-http(s) target like a local file playable — is skipped with a
|
||||
warning instead of aborting, since queue/bookmark captures mix
|
||||
providers. Real download failures (bad status, transport, timeout) stay
|
||||
fatal. `architecture/captures.md` D3/D4 reconciled; new tests
|
||||
`downloadable_instances_flag_every_node` (fsdy) and
|
||||
`download_capture_skips_uncapturable_tracks` (capture store).
|
||||
|
||||
## youtube-provider (2026-07-21)
|
||||
|
||||
Built per `plan/youtube-provider.md`: a new workspace crate **`ytdy`**
|
||||
mounts YouTube at `/youtube`, backed by a `yt-dlp` subprocess (declared
|
||||
in `devenv.nix`). All extraction goes through one `Engine` seam:
|
||||
argv-only invocations with `--no-warnings`, an optional `--cookies`
|
||||
flag, a per-call timeout (`kill_on_drop`), a 32 MiB stdout cap, and
|
||||
typed `EngineError`s — tests drive the whole provider through a fake
|
||||
shell-script binary, no network.
|
||||
|
||||
Search needs no login and mirrors tidal's search exactly: `%` on
|
||||
`/youtube/search` creates an in-memory term (deduplicated, implicitly
|
||||
recreated on stale paths, rename re-searches, delete idempotent), whose
|
||||
node lists the top N (`ytsearchN:`, default 20) results as queueable,
|
||||
downloadable tracks. With a readable cookies file configured in
|
||||
`ytdy.toml` ("logged in"), a `playlists` subtree appears
|
||||
(`feed/playlists` flat listing → playlist nodes with tracks); an
|
||||
unreadable cookies file degrades to logged-out with a warning, never a
|
||||
failed init. Streams resolve via `-f bestaudio/best -g`; captures work
|
||||
end to end (`extension_for` gained `audio/webm → webm`). The
|
||||
orchestrator wires `/youtube` non-fatally: a failed `--version` probe
|
||||
disables the provider, nothing else.
|
||||
|
||||
Deviations: `Entry` keeps separate `uploader`/`channel` fields with an
|
||||
`artist()` preference — the planned serde alias rejects real yt-dlp
|
||||
output as a duplicate field (found by the live probe). The live probe
|
||||
validated search, stream resolution, and a real download capture
|
||||
(252 KB webm) through the capture store; the **playlists feed
|
||||
invocation is live-unvalidated** (no cookies on this machine) — flagged
|
||||
in `architecture/youtube-provider.md` as the standing risk. 150
|
||||
workspace tests green (8 new in `ytdy`); every gate in
|
||||
`quality/youtube-provider.md` checked.
|
||||
|
||||
## captures (2026-07-21)
|
||||
|
||||
Built per `plan/captures.md`: `W` (shift) on a downloadable library
|
||||
selection captures the subtree like a bookmark, but into
|
||||
`<config>/crabidy/captures/<name>/` with every track's audio
|
||||
**downloaded** next to its order-prefixed toml — the toml's playable is
|
||||
the audio file's *relative* name (`TrackFile::from_track_with_file`), so
|
||||
a capture plays with no provider round trip and the folder stays
|
||||
relocatable. `/captures` is a fourth `fsdy` instance (editable top
|
||||
level, nothing reserved): browse, queue, rename (`e`), delete (`d`),
|
||||
and re-capture to refresh; the audio files are invisible to listings
|
||||
(only dirs and `*.cbd-track.toml` count).
|
||||
|
||||
The bookmark walk was extracted into `capture.rs`
|
||||
(`capture_into`/`write_tree`, `Caps`, one `CaptureError` for both
|
||||
stores) parameterized by a per-track `Sink` — `Link` is byte-identical
|
||||
bookmark behavior, `Download` fetches the first `get_urls_for_track`
|
||||
URL through one shared reqwest client (30 s connect timeout, 600 s
|
||||
per-track deadline, no retries), streams the body to disk against a
|
||||
capture-wide byte budget, picks the extension from `Content-Type` (URL
|
||||
path, then `bin`, as fallbacks), and writes the toml only after the
|
||||
audio succeeded. Download caps: 1 000 dirs, 500 tracks, 4 GiB. Still
|
||||
all-or-nothing with temp cleanup; downloads are sequential inside the
|
||||
one spawned capture task. Download error messages carry the track's
|
||||
library path, never the stream URL (`reqwest::Error::without_url`).
|
||||
|
||||
Nodes opt in via new additive proto flags
|
||||
(`LibraryNode.is_downloadable = 8`, `LibraryNodeChild = 7`). Tidal sets
|
||||
them centrally at the end of `get_lib_node`: downloadable = queueable
|
||||
**or lists tracks** (so search-term track results are downloadable even
|
||||
though the term node isn't queueable); children mirror `is_queable`;
|
||||
tracks inherit their node's flag in the TUI. The server re-enforces at
|
||||
the capture root (`Unsupported` → `failed_precondition`); the rpc
|
||||
gained `CaptureLibraryNodeRequest.download = 3` (additive; old clients
|
||||
keep bookmarking).
|
||||
|
||||
Deviations from the plan/architecture: the naming helper ended up
|
||||
`audio_file_name` (the path variant was clippy-dead); the tidal flag
|
||||
rule grew the "or lists tracks" clause (architecture D4 reconciled);
|
||||
the live probe downloaded a single real track (8.6 MB m4a,
|
||||
Content-Type-derived extension, replayed through a `/captures`
|
||||
instance) instead of a whole album — the multi-track walk is
|
||||
unit-covered and a full album download is needlessly heavy for a smoke
|
||||
test. 142 workspace tests green (1 new in `fsdy`, 8 in
|
||||
`capture`/`capture_store`, 3 TUI + 1 extended); every gate in
|
||||
`quality/captures.md` checked.
|
||||
|
||||
## bookmarks (2026-07-21)
|
||||
|
||||
Built per `plan/bookmarks.md`: `w` on a queueable library selection now
|
||||
captures the whole subtree as a **bookmark** — a structure-preserving
|
||||
snapshot under `<config>/crabidy/bookmarks/<name>/`, mounted read-only at
|
||||
`/bookmarks` by a third `fsdy` instance. The capture runs on the
|
||||
orchestrator (a spawned task walking `get_lib_node` iteratively across
|
||||
any provider): every child node becomes an order-prefixed folder
|
||||
(`fsdy::dir_name`, sharing the queue entries' sanitizer), every track an
|
||||
order-prefixed link file, so the case-insensitive listing reproduces the
|
||||
source order and replaying is plain fs-provider behavior. Caps (1 000
|
||||
dirs / 20 000 tracks) abort cleanly with the temp folder removed; writes
|
||||
are tmp-and-swap; re-capturing a name overwrites it. The wire gained one
|
||||
additive rpc, `CaptureLibraryNode(path, name)` (invalid name/source →
|
||||
`invalid_argument`, over-cap/disabled → `failed_precondition`). The TUI
|
||||
opens the existing input overlay prefilled with the selection's title
|
||||
(`bookmark`), gated on a queueable bare selection.
|
||||
|
||||
On top, `fsdy::Client` gained `with_editable_top_level(reserved)`:
|
||||
editable instances mark their root's child folders
|
||||
`is_editable`/`is_deletable` and implement rename (no-merge, validated
|
||||
titles, returns the renamed node) and delete (idempotent, returns the
|
||||
refreshed root). Applied to `/bookmarks` (nothing reserved) **and
|
||||
`/queues`** (reserved: `current`) — saved queues are now renamable and
|
||||
deletable through the existing `e`/`d` flows with zero TUI changes.
|
||||
`/fs` stays immutable. All 130 workspace tests green (6 new in `fsdy`, 7
|
||||
in `bookmark_store`, 2 TUI); every gate in `quality/bookmarks.md`
|
||||
checked. A temporary live probe (removed after passing) captured a
|
||||
19-track album from the live Tidal API, browsed it with editable flags,
|
||||
renamed it, resolved it in order, and fetched a stream URL for a
|
||||
captured link.
|
||||
|
||||
The whole feature ran autonomously per standing instruction; decisions
|
||||
are recorded in `architecture/bookmarks.md` (options + rationale).
|
||||
|
||||
### Deviations from plan / architecture (bookmarks)
|
||||
|
||||
- **Capture is all-or-nothing**: any provider or write failure mid-walk
|
||||
aborts the whole capture (temp folder removed) instead of skipping the
|
||||
failing subtree with a warning — a bookmark that *looks* complete must
|
||||
*be* complete. The architecture only specified the unreadable-*root*
|
||||
case; this extends it to every node.
|
||||
- **Rename to the current name is a no-op success** (returns the node),
|
||||
not a collision error — the target "exists" only because it is the
|
||||
source.
|
||||
- **Rename targets don't pass `disk_path`**: the new folder name is
|
||||
validated by `validate_folder_name` (no separators, NUL, or leading
|
||||
dots), which makes it a plain sibling name by construction; the
|
||||
traversal gate still covers every client-supplied *path*.
|
||||
- **`track_file_name` was refactored onto a shared `ordered_name`**
|
||||
helper rather than duplicated for `dir_name` (planned as "shared
|
||||
sanitizer", realized as one function).
|
||||
- **Environment note**: builds/tests again ran with a session-local
|
||||
`CARGO_TARGET_DIR`; no repo change.
|
||||
|
||||
## queue-persistence (2026-07-21)
|
||||
|
||||
Built per `plan/queue-persistence.md`: queues now survive server restarts,
|
||||
realized entirely on top of the fs provider. `fsdy::Client` became
|
||||
instance-mountable (`Client::new(provider_root, disk_root)`); the
|
||||
orchestrator mounts a second, read-only instance at `/queues` over
|
||||
`<config>/crabidy/queues/`, so saved queues are ordinary browsable,
|
||||
queueable library folders. Every queue is a folder of order-prefixed
|
||||
(`0001 <title>.cbd-track.toml`) **link** files — metadata copied from the
|
||||
queue entry, `playable.link = Track.path` — written only by the new
|
||||
`crabidy_server::queue_store::QueueStore` (tmp-and-swap, hidden
|
||||
`.queue-state.toml` sidecar for position/repeat/shuffle). The playback
|
||||
loop feeds every queue-state change into a latest-wins `watch` channel; a
|
||||
persister task debounces, skips unchanged snapshots, and rewrites
|
||||
`queues/current/`. On startup the server restores tracks, position, and
|
||||
modifiers from `current/` without ever starting playback. `w` on the TUI
|
||||
queue pane opens the existing input overlay (`save queue`) and drives the
|
||||
previously stubbed `SaveQueue` rpc (invalid name → `invalid_argument`,
|
||||
empty queue/disabled persistence → `failed_precondition`). Reloading a
|
||||
saved queue is just queueing `/queues/<name>` — the listing rewrites each
|
||||
link back to its target, so zero new resolve mechanisms. All 116
|
||||
workspace tests green (10 new in `fsdy`, 9 in `queue_store`, 5 playback,
|
||||
3 TUI); every gate in `quality/queue-persistence.md` checked. A temporary
|
||||
live probe (removed after passing) round-tripped a mixed queue — a track
|
||||
fetched from the live Tidal API plus an fs url track — through persist,
|
||||
reload, `/queues` listing, and the resolve walk, and the reloaded Tidal
|
||||
path still yielded a stream URL.
|
||||
|
||||
The whole feature ran autonomously per standing instruction; decisions
|
||||
are recorded in `architecture/queue-persistence.md` (options + rationale).
|
||||
|
||||
### Deviations from plan / architecture (queue-persistence)
|
||||
|
||||
- **The "no links into `/fs`" rule was dropped** (fs-provider D3): queue
|
||||
entries persist as links to whatever path the queue held, including
|
||||
`/fs/...` tracks. Replaced by the one-hop argument —
|
||||
`get_urls_for_track` never follows a link, so chains die at play time
|
||||
and cycles cannot recurse. `architecture/fs-provider.md` reconciled.
|
||||
- **`SaveQueueError` gained `Disabled` and `State` variants** beyond the
|
||||
stub: `Disabled` (no usable queues directory) maps to
|
||||
`failed_precondition` instead of masquerading as I/O; `State` covers
|
||||
sidecar serialization.
|
||||
- **The orchestrator mounts `/queues` independently of `QueueStore`**:
|
||||
both derive the directory from `queue_store::queues_dir()`, so a
|
||||
mount over a not-yet-created folder simply lists as missing until the
|
||||
store (created in `main`) writes it. No plumbing between the two.
|
||||
- **Shuffle order is not persisted** (documented in D3/D4 but worth
|
||||
repeating): restoring `shuffle = true` reshuffles around the restored
|
||||
current track.
|
||||
- **The live probe needed no bespoke server run**: provider-layer clients
|
||||
plus `QueueStore` cover the full D2/D5 story; the gRPC and TUI layers
|
||||
above are unit-tested.
|
||||
- **Environment note**: builds/tests again ran with a session-local
|
||||
`CARGO_TARGET_DIR`; no repo change.
|
||||
|
||||
## fs-provider (2026-07-21)
|
||||
|
||||
Built per `plan/fs-provider.md`: a second media provider (crate `fsdy`,
|
||||
`/fs`) that walks one configured root directory and treats
|
||||
`*.cbd-track.toml` files as serialized track nodes — metadata plus exactly
|
||||
one playable reference: a local audio file (absolute or relative to the
|
||||
track file), an http(s) URL, or a crabidy-internal link. The wire types
|
||||
are unchanged (architecture D1): the only new datastructure is the
|
||||
on-disk TOML schema. Link tracks rewrite `Track.path` to the target at
|
||||
listing time (D2), so playback routes to the owning provider through the
|
||||
orchestrator's existing prefix routing with zero new mechanisms; links
|
||||
into `/fs` are rejected at parse time, making chains impossible.
|
||||
Directories list sorted and queue via the default chunked resolve walk;
|
||||
client paths are decoded and validated in a single helper so they cannot
|
||||
escape the root; symlinks, hidden entries, and broken files are skipped
|
||||
with warnings. `ProviderOrchestrator` gained an optional fs client
|
||||
(non-fatal init from `fsdy.toml`, default root `dirs::audio_dir()`) and
|
||||
`/fs` routing arms in every trait method. No player or TUI changes were
|
||||
needed. All 91 workspace tests green (15 new in `fsdy`); every gate in
|
||||
`quality/fs-provider.md` checked. A temporary live probe (removed after
|
||||
passing) built a real tree whose link track pointed at a track fetched
|
||||
from the live Tidal API: listing order held, the link path was
|
||||
rewritten, and the target resolved a stream URL — the full D2 story
|
||||
end-to-end.
|
||||
|
||||
The whole feature ran autonomously per standing instruction; decisions
|
||||
are recorded in `architecture/fs-provider.md` (options + rationale).
|
||||
|
||||
### Deviations from plan / architecture (fs-provider)
|
||||
|
||||
- **Extension renamed to `.cbd-track.toml`** (user request, follow-up
|
||||
commit): the original `.track.toml` was too generic; the `cbd-` prefix
|
||||
makes the files unmistakably crabidy's.
|
||||
- **`TrackFileError::UrlScheme` carries only the scheme**, not the URL:
|
||||
the parse error ends up in skip-warnings, and a private stream URL may
|
||||
embed a token (quality gate "no file contents in logs"). The
|
||||
architecture's schema and behavior are otherwise as designed.
|
||||
- **The live probe ran at the provider layer**, not against a running
|
||||
server (no interactive terminal/audio device here, same as previous
|
||||
features): `fsdy` and `tidaldy` clients driven directly, mimicking the
|
||||
orchestrator's routing exactly. It also had to *fetch* its link target
|
||||
first — the well-known id from the progressive-queueing probe is an
|
||||
album path, and a link must point at a track.
|
||||
- **`get_lib_node` on a track path is `MalformedPath`** — implicit in
|
||||
the design, made explicit so the default resolve walk can never
|
||||
mistake a track file for a directory.
|
||||
- **Environment note**: builds/tests again ran with a session-local
|
||||
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo
|
||||
change.
|
||||
|
||||
## progressive-queueing (2026-07-21)
|
||||
|
||||
Built per `plan/progressive-queueing.md`: queueing a large nested collection
|
||||
now fills the queue progressively instead of freezing until the full
|
||||
resolve. `ProviderClient` gained `resolve_tracks_into` (chunk-streaming over
|
||||
a bounded channel; sender-drop = done, receiver-drop = cancel) with a
|
||||
default pre-order walk; tidaldy overrides it so playlists and albums emit
|
||||
one chunk per fetched 50-track page. The playback loop registers a pending
|
||||
op per queue command, spawns a forwarder, applies chunks on the loop
|
||||
(single-writer preserved), broadcasts after every chunk, and starts playback
|
||||
with the first chunk that makes a track current. `Replace`/`Clear` cancel
|
||||
in-flight resolves down to the HTTP fetch. The wire gained
|
||||
`Queue.resolving = 4` (additive); the TUI renders an animated one-to-three
|
||||
dots pseudo-item after the last queue row while it is set. All 76 workspace
|
||||
tests green; every gate in `quality/progressive-queueing.md` checked.
|
||||
Verified against the live Tidal API with a temporary ignored probe (removed
|
||||
after passing): a 71-album artist streamed its first 19-track chunk (first
|
||||
album, listing order) while the walk was still running, and dropping the
|
||||
receiver mid-stream ended the resolve cleanly in 1.8 s instead of draining
|
||||
the discography.
|
||||
|
||||
The whole feature ran autonomously per standing instruction; decisions are
|
||||
recorded in `architecture/progressive-queueing.md` (options + rationale).
|
||||
|
||||
### Deviations from plan / architecture (progressive-queueing)
|
||||
|
||||
- **`make_paginated_request_into` became `stream_track_pages_into`**: the
|
||||
planned generic `AsyncFnMut` page sink dies on a rustc
|
||||
"implementation of `Send` is not general enough" limitation inside
|
||||
`async_trait` methods. The concrete method (fixed `Track` item type,
|
||||
proto mapping and channel send inlined) sidesteps it with the same
|
||||
page-loop and cancellation semantics.
|
||||
- **Zero-track warning lives in the forwarder, not `finish_resolve`**: the
|
||||
forwarder sees each path and its chunk count, so the existing per-path
|
||||
"resolved to no playable tracks" message survives verbatim; the planned
|
||||
op-level warning would have had to smuggle paths into `PendingResolve`.
|
||||
- **Fixed alongside (user-reported)**: Enter on a non-queueable library
|
||||
item used to blank the queue while audio kept playing. Two causes, both
|
||||
fixed: `Library::get_selected` now gates the bare selection on
|
||||
`is_queable` (marks were already gated), and a replace that resolves to
|
||||
zero tracks no longer touches the queue at all — structurally, since the
|
||||
queue is only mutated by arriving chunks. Regression test
|
||||
`queue_ops_ignore_non_queueable_selections`.
|
||||
- **`Queue` (play-next) captures the current position when the command
|
||||
arrives**, not per chunk: chunks of one op stay contiguous after the
|
||||
track the user was on when they pressed the key, even if playback
|
||||
advances mid-resolve.
|
||||
- **Live probe scope**: the first full-discography probe was cut short
|
||||
(hundreds of album fetches for no extra signal) and replaced by a
|
||||
receive-two-chunks-then-cancel probe — which also exercises mid-stream
|
||||
cancellation against the live API, which the drain-everything version
|
||||
could not.
|
||||
- **Environment note**: builds/tests again ran with a session-local
|
||||
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo change.
|
||||
|
||||
## node-editing (2026-07-20)
|
||||
|
||||
Built per `plan/node-editing.md`: search-term nodes (created via `%`) are now
|
||||
modifiable — `e` opens the input overlay prefilled with the current title and
|
||||
renames (re-running the search; merge on title collision), `d` deletes
|
||||
without confirmation (documented decision, architecture/node-editing.md D4).
|
||||
Capabilities travel as `LibraryNodeChild.is_editable`/`is_deletable` (fields
|
||||
5/6, child-only — no consumer for node-level copies), surfaced as a `[ed]`
|
||||
marker; two new rpcs `RenameLibraryNode` (returns the renamed node, TUI
|
||||
navigates into it) and `DeleteLibraryNode` (returns the refreshed parent).
|
||||
All 59 workspace tests green; every gate in `quality/node-editing.md`
|
||||
checked. Verified against the live Tidal API with a temporary ignored probe
|
||||
(removed after passing): create `beatles` → rename to `rolling stones`
|
||||
(in-place, 20 tracks / 40 children) → a track queued under the old term
|
||||
still resolved a stream URL → delete emptied the listing.
|
||||
|
||||
The whole feature ran autonomously per standing instruction; decisions are
|
||||
recorded in `architecture/node-editing.md` (options + rationale per topic).
|
||||
|
||||
### Deviations from plan / architecture (node-editing)
|
||||
|
||||
- **Self-rename bug caught by the gate tests**: the first
|
||||
`rename_search_term` implementation deleted a term renamed to itself (the
|
||||
merge branch removed the "old" slot). Fixed with an explicit `old != new`
|
||||
guard; the architecture text ("merge on collision") now implicitly means
|
||||
*distinct* titles.
|
||||
- **`pane_bindings_only_match_their_own_pane` (help-modal suite) updated**:
|
||||
it asserted plain `d` is unbound in the library — now it is
|
||||
`LibraryDeleteNode` by design; the test's queue-only example key moved to
|
||||
`c`.
|
||||
- **`delete_library_node` also maps `InvalidInput` → `invalid_argument`**
|
||||
although no provider raises it for delete today — keeps the error contract
|
||||
uniform across the three node-mutation rpcs.
|
||||
- **End-to-end check ran at the provider layer** (as with search): no
|
||||
interactive terminal/audio device in this environment; the gRPC handler
|
||||
and TUI layers above it are covered by unit tests and review.
|
||||
- **Environment note**: builds/tests again ran with a session-local
|
||||
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo change.
|
||||
|
||||
## search (2026-07-20)
|
||||
|
||||
Built per `plan/search.md`: `%` inside `/tidal/search` opens a one-line input;
|
||||
the term becomes a persistent (per-process) tree node holding Tidal search
|
||||
results — 20 tracks queueable in place, plus artist/album results as canonical
|
||||
`/tidal/artists/...` children. Creatable nodes carry an `is_creatable` flag
|
||||
end-to-end (proto → provider → TUI marker `[%]` + pane hint). All 48 workspace
|
||||
tests green; every gate in `quality/search.md` checked. Verified against the
|
||||
live Tidal API: payload shapes match the existing models (probe kept as the
|
||||
ignored `probe_search_shapes` test), and a full create→list→resolve-URL round
|
||||
trip succeeded (`beatles` → 20 tracks / 40 children, idempotent, playable
|
||||
stream URL from a search-track path).
|
||||
|
||||
The whole feature ran autonomously on user instruction; decisions were taken
|
||||
without mid-stage confirmation and recorded in `architecture/search.md`
|
||||
(options + decision per topic).
|
||||
|
||||
### Deviations from plan / architecture (search)
|
||||
|
||||
- **`Library::update` now concatenates tracks and children** (tracks first).
|
||||
The old code showed tracks *instead of* children, which would have hidden
|
||||
the artist/album results on term nodes — architecture assumed both would
|
||||
render. Existing nodes are unaffected (they only ever carry one kind).
|
||||
- **Search categories degrade independently**: a failing category logs and
|
||||
contributes nothing; only all three failing is a `FetchError`. The plan
|
||||
did not specify partial-failure behavior.
|
||||
- **`get_lib_node` no longer requires a user id up front** — the gate moved
|
||||
into the favorites arms (planned), which also means `create_lib_node`
|
||||
validation works fully offline (used by the new unit tests).
|
||||
- **End-to-end check ran at the provider layer** (temporary ignored test,
|
||||
removed after passing) rather than driving the full TUI + server — no
|
||||
interactive terminal/audio device in this environment. The gRPC handler and
|
||||
TUI layers above it are covered by unit tests and review.
|
||||
- **Environment note**: `target/` contains owner-built artifacts not writable
|
||||
by this agent's user; builds/tests ran with a session-local
|
||||
`CARGO_TARGET_DIR`. No repo change involved.
|
||||
|
||||
## help-modal (2026-07-20)
|
||||
|
||||
Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
|
||||
`BINDINGS` table + `lookup` + `key_label`), `app/help.rs` (overlay), the
|
||||
`App::dispatch`/`DispatchResult` seam, and the rewired event loop in
|
||||
`main.rs`. All 20 tests pass; every gate in `quality/help-modal.md` checked.
|
||||
|
||||
### Deviations from plan / architecture (help-modal)
|
||||
|
||||
- **Two-column modal layout.** The architecture assumed a single-column list;
|
||||
the full table is ~50 rows and would not fit even a 100×40 frame. The modal
|
||||
renders Global in the left column and Library + Queue stacked in the right
|
||||
column, with the close keys as a footer line (`Close help: ?, Esc, q`)
|
||||
derived from the `Scope::Help` bindings instead of a fourth listed group.
|
||||
The open question "scroll vs truncate" stays resolved as truncate — but
|
||||
after the column split the content fits ~34×94, so truncation only kicks in
|
||||
on genuinely small terminals.
|
||||
- **`Scope` derives `Hash`** (not in the stub) so the chord-uniqueness test
|
||||
can use a `HashSet`.
|
||||
- **`QueueInsertHere` description reworded** to "Insert library selection
|
||||
after this track": `crabidy-server`'s `insert_tracks` splices at
|
||||
`position + 1`. Same check confirmed the planned "Queue selection after
|
||||
current track" wording for `LibraryQueueNext`.
|
||||
- **`main.rs`** passes `tx` to `App::new` without the now-unneeded clone; the
|
||||
`KeyCode`/`KeyModifiers`/`UiFocus`/`StatefulList` imports moved out with the
|
||||
old match.
|
||||
|
||||
## capture-deletion (2026-07-21)
|
||||
|
||||
Deletes under `/captures` now work at any depth and remove data from
|
||||
disk, behind a TUI confirmation (`architecture/capture-deletion.md`;
|
||||
direct implementation, no separate plan file — the change is four
|
||||
bounded seams):
|
||||
|
||||
- **Proto**: `LibraryNode.tracks_deletable` (field 9) — node-level
|
||||
"listed tracks may be deleted", mirroring the `is_downloadable`
|
||||
inheritance so no `Track` literal anywhere had to change.
|
||||
`DeleteLibraryNode` doc extended to tracks and recursive folders.
|
||||
- **fsdy**: `with_deletable_tree()` (only the `/captures` instance sets
|
||||
it): nested folders delete recursively; track deletes remove the toml
|
||||
plus its `[playable] file` audio **iff** the canonicalized audio path
|
||||
stays inside the canonicalized instance root (`..`/symlink-proof);
|
||||
reserved names and the instance root remain undeletable; everything
|
||||
idempotent. Deletes now return the actual parent listing (was: root —
|
||||
identical for the previously-only-possible top-level case).
|
||||
- **crabidy-server**: captures fsdy instance gains the flag; the
|
||||
delete RPC path was already generic.
|
||||
- **cbd-tui**: tracks inherit `is_deletable` from `tracks_deletable`;
|
||||
`d` under `/captures` opens a modal red `delete <title>? [y/N]` line
|
||||
(only `y`/`Y` sends, any other key cancels) — other deletables stay
|
||||
unconfirmed by design; `selected_deletable()` now returns
|
||||
`(path, title)`.
|
||||
|
||||
Tests: 4 new fsdy tests (flags, recursive delete, track+audio delete,
|
||||
outside-root audio kept) and 3 new TUI tests (confirm-then-send,
|
||||
cancel-on-anything-else, prompt render) plus a guard in the existing
|
||||
queues delete test that track deletion stays `NotSupported` there.
|
||||
188 workspace tests green; clippy `-D warnings` and fmt clean.
|
||||
|
||||
## roles-auth (2026-07-21)
|
||||
|
||||
Built per `plan/roles-auth.md` from `architecture/roles-auth.md`:
|
||||
basic-auth role authorization (owner / queue-owner / queue-appender)
|
||||
with PHC password hashes in the new `crabidy-server.toml`.
|
||||
|
||||
- `crabidy-server/src/settings.rs` — `[auth]` loading; missing file =
|
||||
open mode, malformed file = startup abort (fail-closed).
|
||||
- `crabidy-server/src/auth.rs` — ordered `Role`, `minimum_role`
|
||||
default-deny method table (pinned by a 24-method test),
|
||||
`Authenticator` (argon2 verify, success-only credential cache,
|
||||
indistinguishable failures), `AuthLayer`/`AuthService` tower layer
|
||||
answering trailers-only `UNAUTHENTICATED`/`PERMISSION_DENIED` via
|
||||
`Status::into_http()`, and `hash_password` for the new
|
||||
`crabidy-server hash-password` subcommand (clap, stdin → PHC).
|
||||
- `cbd-tui` — `user`/`password` config options and flags;
|
||||
`AuthInterceptor` baking the Basic header into every request via
|
||||
`CrabidyServiceClient::with_interceptor`.
|
||||
|
||||
### Deviations from plan / architecture (roles-auth)
|
||||
|
||||
- None functionally. The dev-flow stages were compressed into one
|
||||
autonomous pass (per standing instruction): stubs went straight to
|
||||
implementation; `quality/roles-auth.md` gates were verified after
|
||||
the fact and all hold.
|
||||
- Denied-action UX in the TUI stays a logged no-op, as recorded in the
|
||||
architecture's open questions.
|
||||
|
||||
## web-client (2026-07-21)
|
||||
|
||||
A Leptos/WASM browser client with TUI feature parity, served by
|
||||
crabidy-server itself. Full dev-flow run: `architecture/web-client.md`,
|
||||
`quality/web-client.md`, `plan/web-client.md`.
|
||||
|
||||
New workspace member **cbd-web** (CSR Leptos):
|
||||
|
||||
- `state.rs` / `keymap.rs` — the TUI's pane logic and bindings ported
|
||||
as pure, DOM-free modules with native `#[test]`s (18 tests). Same
|
||||
semantics: tracks-before-children, cursor memory, marks-win, capture
|
||||
progress lines, `is_cacheable`, capture-delete confirmation rule.
|
||||
- `rpc.rs` — gRPC-web (`tonic-web-wasm-client`) over the same
|
||||
`crabidy-core` generated client and types as the TUI, with the same
|
||||
basic-auth header interceptor.
|
||||
- `app.rs` — one signal store fed by the update stream (reconnecting
|
||||
backoff), one dispatcher mirroring the TUI dispatch, thin components:
|
||||
library/queue panes, transport bar, name/confirm/login/help dialogs,
|
||||
global keyboard wiring.
|
||||
- `style.css` — pure modern CSS, single `--accent` crab orange-red with
|
||||
`color-mix` derivations, light/dark via `color-scheme`+`light-dark()`
|
||||
plus a persisted toggle, phone breakpoint.
|
||||
|
||||
crabidy-server changes:
|
||||
|
||||
- `web-ui` cargo feature (**default on**); `--no-default-features` =
|
||||
headless gRPC-only.
|
||||
- `build.rs` stages `cbd-web/dist` into `OUT_DIR` (or a placeholder
|
||||
page — plain `cargo build` needs no wasm toolchain), embedded via
|
||||
`include_dir`.
|
||||
- `web.rs` serves the embedded bundle (GET/HEAD, index fallback).
|
||||
- `serve()` refactored to `build_router()`: one axum router with the
|
||||
gRPC service (auth layer → `tonic-web` GrpcWebLayer → service) as a
|
||||
route and the web bundle as fallback; `axum::serve` replaces
|
||||
`tonic::transport::Server`.
|
||||
|
||||
Cross-cutting:
|
||||
|
||||
- crabidy-core builds for `wasm32-unknown-unknown`: workspace `tonic`
|
||||
set `default-features = false`, this crate takes codegen-only, native
|
||||
binaries re-enable transport/router/channel; `build.rs` uses
|
||||
`build_transport(false)`; config loading gated to non-wasm.
|
||||
- devenv: `trunk`, `wasm-bindgen-cli`, `binaryen`, the wasm target, and
|
||||
`build-web`/`serve-web` scripts (which clear `RUSTFLAGS` — the mold
|
||||
linker flag breaks `rust-lld`).
|
||||
|
||||
### Deviations from plan / architecture (web-client)
|
||||
|
||||
- **No CRDT / local-first sync layer** (the example template's
|
||||
automerge/loro): this app is a remote control for one live server
|
||||
state, so "local first" was scoped to CSR + no-CDN assets + in-memory
|
||||
caching + localStorage prefs + reconnect. Recorded in the
|
||||
architecture doc up front.
|
||||
- **`build_router()` extracted** from `serve()` (not in the plan) so
|
||||
the three-way routing + auth composition is testable without a live
|
||||
provider backend (`tests/web_server.rs`, incl. a native-gRPC-over-
|
||||
axum h2c check).
|
||||
- **Native dead-code allow** on the cbd-web binary target: the pure
|
||||
modules are used by wasm + tests, not the native stub binary.
|
||||
|
||||
### Verification
|
||||
|
||||
202→ tests green across the workspace plus 4 new server routing tests
|
||||
and 18 cbd-web logic tests; native and wasm clippy `-D warnings` clean;
|
||||
fmt + markdownlint clean. Live smoke test (server with the real
|
||||
embedded bundle + stubbed Tidal): `/` serves the shell, the 1.77 MB
|
||||
wasm/js/css assets serve with correct content-types, deep links fall
|
||||
back to the shell, unauthenticated and wrong-role gRPC-web calls return
|
||||
UNAUTHENTICATED, and a native tonic client round-trips over axum.
|
||||
|
||||
## tui-search (2026-07-21)
|
||||
|
||||
`/` in the library or queue pane opens a live substring filter
|
||||
(`architecture/tui-search.md`). Implemented directly (small feature).
|
||||
|
||||
- New `Filter` helper in `cbd-tui/src/app/list.rs`: keeps the pane's
|
||||
full list, records visible real indices, maps view↔real. Both panes
|
||||
route selection, marks, rendering, and `StatefulList` size through
|
||||
it, so all movement keys work on the filtered view unchanged and the
|
||||
queue's server-facing positions (remove/set-current) map back to real
|
||||
indices.
|
||||
- App gains a modal `search: Option<SearchState>` and
|
||||
`handle_search_key` (type = live filter, Enter keeps, Esc clears),
|
||||
an `OpenSearch` action bound to `/` in both pane scopes targeting the
|
||||
focused pane, and event-loop routing ahead of the input overlay.
|
||||
- Library resets search on node change; queue preserves it across the
|
||||
frequent stream updates.
|
||||
|
||||
Tests: 2 queue tests (real-position mapping on removal, filter survives
|
||||
updates) + 3 app tests (library filter/Enter/Esc lifecycle, focus
|
||||
targeting, dive-clears-filter). 67 cbd-tui tests green; clippy clean.
|
||||
|
||||
Scope: TUI only, per the request; web-client parity noted as a
|
||||
follow-up.
|
||||
|
||||
## client-configs (2026-07-21)
|
||||
|
||||
`cbd` now reads its own `cbd.toml` instead of sharing `cbd-tui.toml`
|
||||
(`architecture/client-configs.md`, resolving the open risk in
|
||||
`architecture/cbd-bundle.md`). One-line change in `cbd/src/main.rs`
|
||||
(`init_config("cbd.toml")`); same `ServerConfig` type and localhost
|
||||
default, so `cbd` (local, self-contained) and a remote-pointed
|
||||
`cbd-tui` coexist on one machine without their `address` settings
|
||||
colliding. README config table + client-config section updated.
|
||||
|
||||
## spectrum (2026-07-21)
|
||||
|
||||
A frequency-spectrum bar row under the track progress
|
||||
(`architecture/spectrum.md`). Because the audio plays on the server and
|
||||
clients may be remote, the spectrum is produced server-side and
|
||||
streamed — a local loopback capture (BeSpec's model) could not serve a
|
||||
remote `cbd-tui`.
|
||||
|
||||
- **audio-player**: `SpectrumTap` (a fixed lock-free ring of 2048 `f32`
|
||||
slots, atomic write index doubling as an idle counter) and
|
||||
`TappingSource`, which wraps the decoded rodio source and mirrors
|
||||
each played frame (downmixed to mono) into the tap on the audio
|
||||
thread — one store per sample, no locks/alloc/logging; `try_seek`
|
||||
delegated so seeking still works. Exposed via `Player::spectrum_tap`.
|
||||
- **crabidy-server**: `spectrum::SpectrumAnalyzer` (Hann window +
|
||||
realfft, log-spaced bins, dBFS→[0,1]) and a ~20 fps task that skips
|
||||
when no stream subscribers, snapshots the tap, and broadcasts a new
|
||||
`StreamUpdate::Spectrum(SpectrumFrame{bins})`; it diffs the tap's
|
||||
frame counter to emit a single zero frame on going idle (bars fall,
|
||||
don't freeze) without touching the player command path.
|
||||
- **proto**: `SpectrumFrame` + oneof field 9 on `GetUpdateStream`.
|
||||
- **cbd-tui**: a bar row (block glyphs `▁..█`, accent color) under the
|
||||
progress gauge in the now-playing pane; `spectrum` config option
|
||||
(default true) on both `cbd-tui.toml` and `cbd.toml`.
|
||||
- **cbd-web**: the same bins rendered as CSS-height accent bars
|
||||
(parity).
|
||||
|
||||
Tests: 2 tap tests (downmix + snapshot ordering), 4 DSP tests (silence,
|
||||
tone concentrates in one region + stays normalized, wrong-length is
|
||||
zero-not-panic, monotonic bin edges), 3 TUI render tests (glyph
|
||||
mapping, bars shown when enabled, hidden when disabled). All workspace
|
||||
tests green; clippy (native + wasm) and fmt clean.
|
||||
|
||||
**Not exercised**: the end-to-end audio→FFT→stream path needs a real
|
||||
audio output device, unavailable in this headless environment. The
|
||||
components are unit-tested and the wiring compiles and starts; the live
|
||||
path should be sanity-checked on a machine with audio.
|
||||
|
||||
## capture-local-files + queue-W (2026-07-22)
|
||||
|
||||
Two capture fixes (the first two of a set; store relocation and a
|
||||
central dedup store come as a later refactor).
|
||||
|
||||
- **Capturing local playables (#4, `capture.rs`)**: a download capture
|
||||
used to record any non-http(s) playable as *skipped* — so capturing
|
||||
an fs node or a queue mixing streamed and local tracks produced red,
|
||||
audioless entries even though the audio was on disk. `fetch_track`
|
||||
now routes a local-file source to a new `copy_local`, which copies the
|
||||
file in next to its toml (source extension preserved, counted against
|
||||
the run's byte budget); a missing/unreadable source still records
|
||||
skipped. Rewrote the mixed-queue test to assert copy-not-skip and
|
||||
added a direct local-copy test.
|
||||
- **`W` on the queue (#3, `cbd-tui`)**: the queue pane bound only `w`
|
||||
(save); capturing the queue meant save-then-navigate-then-`W`. Added
|
||||
`QueueDownloadCapture` on Shift-`W` in the queue scope, which
|
||||
download-captures `/queues/current` (the continuously persisted live
|
||||
queue) via the usual name dialog. Bound + dispatch + tests.
|
||||
|
||||
Docs: architecture/incremental-captures.md D2 updated (local files
|
||||
copied, not skipped); root README capture section updated (queue `W`,
|
||||
local-copy behavior).
|
||||
|
||||
Deferred to the refactor: moving queues/bookmarks/captures out of
|
||||
`.config` into `.local/state` (with migration), and a central
|
||||
content-addressed audio store so captures dedup and link instead of
|
||||
copy.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Plan — web client
|
||||
|
||||
From `architecture/web-client.md` and `quality/web-client.md`.
|
||||
|
||||
## Toolchain & skeleton (done during api-design)
|
||||
|
||||
- [x] devenv: trunk, wasm-bindgen-cli, binaryen, wasm32 target;
|
||||
`build-web`/`serve-web` scripts (RUSTFLAGS cleared for rust-lld).
|
||||
- [x] crabidy-core on wasm: tonic codegen-only (workspace tonic
|
||||
default-features=false, members re-enable), `build_transport(false)`,
|
||||
native-only config gated. Verify: `cargo check -p crabidy-core
|
||||
--target wasm32-unknown-unknown`.
|
||||
- [x] cbd-web crate: Trunk.toml (+ dev proxy), index.html, style.css
|
||||
skeleton, `state.rs` (pane logic ports + tests), `keymap.rs`
|
||||
(TUI bindings port + tests), `rpc.rs` (gRPC-web client + basic
|
||||
auth header), app shell. Verify: wasm check + native tests +
|
||||
`trunk build --release`.
|
||||
- [x] crabidy-server: `web-ui` feature (default on), build.rs staging
|
||||
(dist or placeholder), `web.rs` static fallback (+tests), serve()
|
||||
on one axum router: auth → grpc-web → service, assets public.
|
||||
Verify: check with/without feature, tests.
|
||||
|
||||
## Implementation
|
||||
|
||||
- [x] **Stream task**: connect `GetUpdateStream` on startup, apply
|
||||
updates to signals (queue, mods, play state, volume, mute,
|
||||
position, capture board), reconnect with capped backoff +
|
||||
`connected` signal. Gate: parity/stream.
|
||||
- [x] **Library pane**: listing from `LibraryPane` state, click =
|
||||
select, double-click/`l` = dive, breadcrumb/`h` = ascend, marks,
|
||||
capability badges (`%`/`[e]`/`[d]` equivalents), skipped red.
|
||||
Library cache honoring `is_cacheable`. Gate: parity/semantics.
|
||||
- [x] **Queue pane**: track list with current highlight + resolving
|
||||
indicator, select/play/remove/clear/save, insert-here from
|
||||
library selection. Gate: parity.
|
||||
- [x] **Transport bar**: play/pause, prev/next, restart, stop-aware
|
||||
play state, volume slider + mute, shuffle/repeat toggles,
|
||||
progress gauge from `TrackPosition`. Gate: parity.
|
||||
- [x] **Dialogs**: name input (create/rename/save-queue/capture with
|
||||
slow-warning label), capture-delete y/N (red), help overlay
|
||||
(`?`), login form on `UNAUTHENTICATED` (localStorage-backed).
|
||||
All modal: keys bypass the keymap. Gate: parity + security.
|
||||
- [x] **Keyboard wiring**: global keydown listener → `keymap::lookup`
|
||||
→ actions; input elements exempt (typing in dialogs). Gate:
|
||||
parity.
|
||||
- [x] **Capture progress lines**: board fed by stream, rendered at the
|
||||
library pane bottom, errors red, linger semantics from state.rs.
|
||||
Gate: parity.
|
||||
- [x] **CSS**: full styling — layout grid, pane focus ring, selection
|
||||
bar, accent `--accent` (crab orange-red) with color-mix
|
||||
derivations, light/dark via light-dark() + persisted toggle,
|
||||
phone breakpoint. Gate: styling.
|
||||
- [x] **README + docs**: cbd-web/README.md (build, dev loop, config),
|
||||
root README (web UI section, feature flag, build-web), update
|
||||
architecture doc if the implementation deviates. Gate: build.
|
||||
- [x] **Verification**: full workspace tests + clippy (native and
|
||||
wasm) + fmt + markdownlint; live smoke test: server with bundle
|
||||
→ browser fetch of `/`, gRPC-web call, TUI gRPC call, auth
|
||||
denial over gRPC-web. Tick `quality/web-client.md`; write
|
||||
`plan/summary.md` section; commit.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Plan: youtube-provider
|
||||
|
||||
Ordered tasks; each names its verification (tests in `ytdy/src/lib.rs`
|
||||
and/or gates in `quality/youtube-provider.md`). The crate skeleton,
|
||||
stubs, workspace/devenv wiring, and all tests exist; the tests fail on
|
||||
`todo!()` at plan time.
|
||||
|
||||
- [x] **T1 — Engine.** `run` (argv-only, `--no-warnings`, cookie flag,
|
||||
timeout with `kill_on_drop`, stdout cap, typed errors), `probe`,
|
||||
`flat_listing`, `video_entry`, `stream_urls`. Verifies:
|
||||
`engine_failures_are_typed_never_panics`, parts of every other test;
|
||||
gates "Engine".
|
||||
- [x] **T2 — Path parsing.** `parse_path` for all `YtPath` shapes;
|
||||
reject everything else. Verifies:
|
||||
`foreign_and_malformed_paths_are_rejected`,
|
||||
`tracks_resolve_streams_and_metadata` (is_track_path).
|
||||
- [x] **T3 — Init.** Settings parse (defaults on broken TOML like the
|
||||
other providers), engine construction, `--version` probe, cookies
|
||||
readability check (degrade to logged out with a warning). Verifies:
|
||||
`init_probes_the_binary`, `root_lists_playlists_only_when_logged_in`.
|
||||
- [x] **T4 — Search.** Term registry (snapshot/register),
|
||||
`search_term_node` (`ytsearchN:`), `create/rename/delete_lib_node`,
|
||||
`get_lib_node` arms for Root/Search/SearchTerm, central
|
||||
downloadable rule. Verifies:
|
||||
`search_terms_are_created_listed_and_searched`,
|
||||
`search_terms_rename_and_delete`.
|
||||
- [x] **T5 — Playlists + tracks.** `playlists_node` (feed URL, login
|
||||
gate), `playlist_node`, `get_urls_for_track` (`-g`),
|
||||
`get_metadata_for_track` (single-video `-J`). Verifies:
|
||||
`playlists_list_and_resolve_when_logged_in`,
|
||||
`tracks_resolve_streams_and_metadata`.
|
||||
- [x] **T6 — Orchestrator wiring.** `youtube_client: Option<Arc<_>>`,
|
||||
non-fatal init from `ytdy.toml` (written back), routing arms in every
|
||||
method, root child; `extension_for` gains `audio/webm`. Verifies:
|
||||
existing orchestrator patterns by inspection; gates "Orchestrator
|
||||
wiring"; `extension_prefers_content_type_then_url_then_bin` extended.
|
||||
- [x] **T7 — Full verification.** Workspace suite green; clippy/fmt/
|
||||
taplo/markdownlint clean; every gate ticked; no `todo!()`.
|
||||
- [x] **T8 — Live smoke test.** With the devenv `yt-dlp`: search a term
|
||||
through the provider, fetch a stream URL for one result, and download
|
||||
a small capture through the capture store — remove the probe
|
||||
afterwards. Validate the playlists feed invocation if cookies are
|
||||
available; otherwise record it as unvalidated in the summary.
|
||||
- [x] **T9 — Docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile `architecture/youtube-provider.md`.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Plan: youtube-rustypipe
|
||||
|
||||
Ordered tasks; each names its verification (ytdy tests and/or gates in
|
||||
`quality/youtube-rustypipe.md`).
|
||||
|
||||
- [x] **T1 — Extractor trait + models.** `Extractor` (search_videos,
|
||||
video, audio_stream_url, saved_playlists, playlist_videos) with
|
||||
`VideoEntry`/`PlaylistEntry` models and a typed `ExtractError`;
|
||||
`Client` holds a `Box<dyn Extractor>`. Verifies: gates "Extractor
|
||||
seam".
|
||||
- [x] **T2 — RustyPipeExtractor.** rustypipe client (rustls, storage
|
||||
dir, timeout, retries), mp4a-preferring stream pick, cookie-txt
|
||||
login with cache-first order, saved playlists + bounded playlist
|
||||
pagination. Verifies: gates "Stream selection", "Login"; live probe.
|
||||
- [x] **T3 — Client rework.** Settings without `binary`; init builds
|
||||
the extractor non-fatally; logged-in gating from the login outcome;
|
||||
all trait methods routed through the seam. Verifies: existing
|
||||
behavioral tests, rewritten against the fake.
|
||||
- [x] **T4 — Tests.** Port every ytdy test from fake-script to
|
||||
FakeExtractor; add stream-preference and login-degradation tests.
|
||||
Verifies: ytdy suite green without network.
|
||||
- [x] **T5 — Environment.** Remove `yt-dlp` from `devenv.nix`; trim
|
||||
ytdy `Cargo.toml` (drop process/io-util, add rustypipe); root
|
||||
workspace dep. Verifies: gates "Environment".
|
||||
- [x] **T6 — Full verification + live probe.** Workspace suite,
|
||||
clippy/fmt/markdownlint; temporary ignored probe: search, stream
|
||||
URL fetch, decode, download capture — removed after passing.
|
||||
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
||||
reconcile the architecture docs.
|
||||
- [x] **T8 — Same-day fix: windowed fetching + stream sidecar.**
|
||||
YouTube caps tokenless URLs at their leading ~1 MiB (measured; offset
|
||||
starts refused; upstream web-client deciphering broken; botguard
|
||||
ineffective through iOS). `WindowedHttpStream` in audio-player,
|
||||
windowed capture downloads, `yt-dlp` back as a stream-URL-only
|
||||
sidecar with degrade-and-fallback, `botguard_bin` passthrough,
|
||||
capture deadline 30 min. Verifies: audio-player windowed tests,
|
||||
capture window-chaining test, ytdy sidecar tests, gates "Stream
|
||||
fetching"; live probe on the failing track.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Quality gates: bookmarks
|
||||
|
||||
Criteria the implementation must satisfy beyond the automatic tests
|
||||
(`fsdy/src/lib.rs`, `crabidy-server/src/bookmark_store.rs`, plus the TUI
|
||||
tests added during implementation). Each gate is pass/fail by reading the
|
||||
code.
|
||||
|
||||
## Capture
|
||||
|
||||
- [x] The walk is iterative (worklist), never recursive — a deep provider
|
||||
tree cannot overflow the stack.
|
||||
- [x] The caps bound *total* directories and tracks and abort with a typed
|
||||
error; the temp folder is removed on every failure path (cap, walk
|
||||
error, write error).
|
||||
- [x] Captures write through the same primitives as queues
|
||||
(`TrackFile::from_track`, `track_file_name`, and `dir_name` sharing one
|
||||
sanitizer) — no second serialization or naming scheme.
|
||||
- [x] Whole-bookmark writes are tmp-and-swap; overwriting an existing
|
||||
bookmark never leaves a mix of old and new entries.
|
||||
- [x] A source path that is a track captures as a folder with one file; an
|
||||
unreadable source is `BadSource`, not a panic or an empty bookmark.
|
||||
- [x] The capture runs on a spawned task; the orchestrator loop keeps
|
||||
serving commands during a large walk.
|
||||
- [x] No file contents in logs (paths, names, and counts only).
|
||||
|
||||
## Mutable top level (fsdy)
|
||||
|
||||
- [x] The `is_editable`/`is_deletable` flags appear **only** on direct
|
||||
child folders of an editable instance's root, never on reserved names,
|
||||
nested nodes, tracks, or immutable instances.
|
||||
- [x] `rename_lib_node`/`delete_lib_node` enforce the same gate they
|
||||
advertise: option on + direct child + not reserved; everything else is
|
||||
`NotSupported` (matching the flags a client saw).
|
||||
- [x] Rename validates the new title with the shared
|
||||
`validate_folder_name` (reserved names rejected as targets) and never
|
||||
merges onto an existing sibling (`InvalidInput`).
|
||||
- [x] Delete is idempotent (already gone → success) and returns the
|
||||
refreshed root listing; rename returns the renamed node at its new
|
||||
path.
|
||||
- [x] The `/queues` instance reserves `current`; `/bookmarks` reserves
|
||||
nothing; `/fs` stays fully immutable.
|
||||
- [x] Path traversal validation still happens only in `disk_path`; rename
|
||||
and delete go through it for every path they touch.
|
||||
|
||||
## RPC and orchestrator
|
||||
|
||||
- [x] `capture_library_node` maps errors: invalid name/source →
|
||||
`invalid_argument`, over-cap or disabled → `failed_precondition`,
|
||||
walk/write failures → `internal`.
|
||||
- [x] The orchestrator routes `/bookmarks` in every `ProviderClient`
|
||||
method (same completeness as `/queues`), and `get_lib_root` lists the
|
||||
`bookmarks` child only when the instance exists.
|
||||
- [x] Bookmarks init is non-fatal: no config dir or an unopenable store
|
||||
disables capture and the `/bookmarks` mount, never the server.
|
||||
|
||||
## TUI
|
||||
|
||||
- [x] `w` is bound in `Scope::Library` (queue's `w` untouched), has a help
|
||||
description, and passes the bindings-table invariant tests unchanged.
|
||||
- [x] The capture overlay opens only for a queueable bare selection,
|
||||
prefilled with the selection's title; marks are ignored.
|
||||
- [x] `MessageFromUi::CaptureNode` reaches the new rpc; a failed capture
|
||||
is logged and never tears down the poll loop.
|
||||
- [x] Renaming/deleting bookmark and saved-queue folders works through the
|
||||
existing `e`/`d` flows with no TUI code changes.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- [x] New public items are documented; docs state error/edge behavior.
|
||||
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||
whole workspace; all tests green.
|
||||
- [x] `architecture/queue-persistence.md` D8 reconciled (rename/delete of
|
||||
saved queues is no longer out of scope).
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# Quality gates: captures
|
||||
|
||||
Criteria the implementation must satisfy beyond the automatic tests
|
||||
(`crabidy-server/src/capture.rs`, `crabidy-server/src/capture_store.rs`,
|
||||
`fsdy/src/lib.rs`, plus the TUI tests added during implementation). Each
|
||||
gate is pass/fail by reading the code.
|
||||
|
||||
## Shared walk (refactor)
|
||||
|
||||
- [x] Bookmarks and download captures run through **one** walk
|
||||
(`capture::capture_into`/`write_tree`); `bookmark_store` keeps no copy
|
||||
of the worklist, caps, tmp-and-swap, or cleanup logic.
|
||||
- [x] The refactor is behavior-preserving for bookmarks: every existing
|
||||
`bookmark_store` test passes unchanged (module path of `CaptureError`
|
||||
aside).
|
||||
- [x] The walk stays iterative (worklist), all-or-nothing, and removes the
|
||||
temp folder on every failure path — including failed downloads and the
|
||||
byte budget.
|
||||
|
||||
## Download sink
|
||||
|
||||
- [x] Every external call is bounded: connect timeout on the shared HTTP
|
||||
client and one per-track deadline covering URL fetch, request, and the
|
||||
whole body stream. No retries.
|
||||
- [x] Bodies are **streamed** to disk (never buffered whole) and counted
|
||||
against the capture's byte budget while streaming; exceeding it is
|
||||
`TooLarge`, not partial data left behind.
|
||||
- [x] Non-2xx responses and transport errors are typed
|
||||
(`CaptureError::Download`) — no panic on any network condition.
|
||||
- [x] Download log lines carry paths, names, and counts — never stream
|
||||
URLs (they embed tokens) and never file contents.
|
||||
- [x] The toml is written only after its audio file succeeded, with a
|
||||
relative `file` playable naming the sibling
|
||||
(`TrackFile::from_track_with_file`); the audio name shares the toml's
|
||||
order prefix and sanitizer (`capture::audio_file_name`).
|
||||
- [x] Downloads run sequentially inside the one spawned capture task; the
|
||||
orchestrator loop keeps serving commands during a capture.
|
||||
|
||||
## Opt-in (is_downloadable)
|
||||
|
||||
- [x] Tidal sets the flag centrally: nodes are downloadable when queueable
|
||||
or when they list tracks; children mirror `is_queable`. No per-arm
|
||||
copies to drift.
|
||||
- [x] Every other provider (fs, queues, bookmarks, captures, orchestrator
|
||||
root) leaves the flag `false` — a capture can never be built from
|
||||
another capture's or bookmark's links masquerading as sources.
|
||||
- [x] The server enforces the blessing at the capture root (directory
|
||||
source: its own node; track source: its parent node) with
|
||||
`CaptureError::Unsupported`; an unreadable root is `BadSource`.
|
||||
|
||||
## RPC and orchestrator
|
||||
|
||||
- [x] `CaptureLibraryNodeRequest.download` is additive: old clients (no
|
||||
flag) keep getting bookmarks, byte-for-byte.
|
||||
- [x] Error mapping: `InvalidName`/`BadSource` → `invalid_argument`;
|
||||
`TooLarge`/`Disabled`/`Unsupported` → `failed_precondition`;
|
||||
download/walk/write failures → `internal`.
|
||||
- [x] The orchestrator routes `/captures` in every `ProviderClient` method
|
||||
(same completeness as `/bookmarks`), and `get_lib_root` lists the
|
||||
`captures` child only when the instance exists.
|
||||
- [x] Captures init is non-fatal: no config dir or an unopenable store
|
||||
disables download captures and the `/captures` mount, never the server
|
||||
(and never bookmarks).
|
||||
- [x] `/captures` mounts with an editable top level (no reserved names):
|
||||
captures are renamable (`e`) and deletable (`d`) like bookmarks.
|
||||
|
||||
## TUI
|
||||
|
||||
- [x] `W` (shift) is bound in `Scope::Library` with a help description and
|
||||
passes the bindings-table invariant tests unchanged; `w` behavior is
|
||||
untouched.
|
||||
- [x] The capture overlay opens for `W` only when the bare selection is
|
||||
queueable **and** downloadable; tracks inherit their node's flag;
|
||||
marks are ignored. Label: `capture` (vs `bookmark`).
|
||||
- [x] `MessageFromUi::CaptureNode` carries `download`; a failed capture is
|
||||
logged and never tears down the poll loop.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- [x] New public items are documented; docs state error/edge behavior.
|
||||
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||
whole workspace; all tests green.
|
||||
- [x] `architecture/captures.md` reconciled where the implementation
|
||||
diverged (e.g. the exact tidal flag rule).
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Quality gates: cbd-bundle
|
||||
|
||||
Criteria beyond the automatic tests (`cbd/src/main.rs` readiness tests
|
||||
plus the moved suites). Each gate is pass/fail by reading the code.
|
||||
|
||||
## Refactor (behavior-preserving)
|
||||
|
||||
- [x] The server's modules (`playback`, `provider`, `rpc`) and command/
|
||||
message types moved to the library **unchanged**; `serve(addr)` is the
|
||||
old `main` body verbatim (minus tracing); both thin mains only set up
|
||||
tracing and delegate.
|
||||
- [x] Every pre-existing test moved along and passes unchanged; no
|
||||
test was rewritten to accommodate the refactor.
|
||||
- [x] `cbd-tui`'s library exposes `run(config)`; tracing setup stays in
|
||||
the binaries (where logs go is a hosting decision).
|
||||
|
||||
## Bundled behavior
|
||||
|
||||
- [x] `cbd` uses the same configs as the pair (`cbd-tui.toml` and the
|
||||
server-side configs) — nothing bundled-specific to configure.
|
||||
- [x] Both halves log to one file; the terminal is never written to
|
||||
outside the TUI.
|
||||
- [x] An already-running standalone server is adopted (occupied port +
|
||||
reachable socket → connect), never treated as an error.
|
||||
- [x] A server that dies before readiness surfaces its actual error;
|
||||
a slow one gets a bounded, generous wait (first-run login flows).
|
||||
- [x] The readiness poll strips the config URL's scheme and never
|
||||
parses beyond host:port.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- [x] New public items are documented; docs state error/edge behavior.
|
||||
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean; all
|
||||
tests green.
|
||||
- [x] `architecture/cbd-bundle.md` reconciled where the implementation
|
||||
diverged.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Quality gates: fs-provider
|
||||
|
||||
Criteria the implementation must satisfy beyond the automated tests in
|
||||
`fsdy/src/lib.rs`. Check each by reading the code and reasoning; tick only
|
||||
when verified.
|
||||
|
||||
## Robustness (hard rules)
|
||||
|
||||
- [x] No code path panics on the contents of the tree: unreadable files,
|
||||
invalid TOML, invalid UTF-8 file names, symlink cycles, and permission
|
||||
errors all end in a typed error or a warn-and-skip — no `unwrap`/
|
||||
`expect`/`panic!`/indexing on tree- or client-derived data outside
|
||||
tests.
|
||||
- [x] Client-supplied paths cannot address anything outside the configured
|
||||
root: every decoded segment is validated (`.`, `..`, empty, separator)
|
||||
before joining, in **one** place that all lookups go through.
|
||||
- [x] All provider I/O is `tokio::fs` — no `std::fs` outside `#[cfg(test)]`
|
||||
(nothing blocks the runtime).
|
||||
- [x] Directory listing skips symlinks without following them (no cycle can
|
||||
hang the walk; the root cannot be escaped via links).
|
||||
|
||||
## Contract fidelity
|
||||
|
||||
- [x] A `link` playable rewrites `Track.path` to the target everywhere a
|
||||
`Track` is built (listing and metadata) — there is a single
|
||||
file-to-`Track` conversion both call.
|
||||
- [x] `link` targets pointing into `/fs` are rejected at parse time, so
|
||||
link chains are structurally impossible.
|
||||
- [x] `get_urls_for_track` returns plain file paths and http(s) URLs only —
|
||||
never `file://` URLs (the player rejects unknown schemes).
|
||||
- [x] A broken track file is skipped **with a warning that names the file**
|
||||
and does not remove its siblings from the listing.
|
||||
- [x] `create/rename/delete_lib_node` return `NotSupported`; no fs mutation
|
||||
API sneaks in.
|
||||
- [x] `resolve_tracks_into` keeps the trait default (no override), and the
|
||||
listing order equals the resolve order (both come from the same sorted
|
||||
listing).
|
||||
|
||||
## Orchestrator wiring
|
||||
|
||||
- [x] Every `ProviderClient` method on `ProviderOrchestrator` routes `/fs`
|
||||
and `/fs/...` to the fs client, mirroring the `/tidal` arms (including
|
||||
`is_track_path` and `resolve_tracks_into`).
|
||||
- [x] fs init failure is non-fatal: the server starts, logs a warning, and
|
||||
`/fs` is absent from the root listing; no `/fs`-routing arm can panic
|
||||
when the client is absent.
|
||||
- [x] The `fsdy.toml` config is written back with effective defaults on
|
||||
first run, like `tidaly.toml`.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- [x] Every public item in `fsdy` has a doc comment stating intent and
|
||||
error behavior; the on-disk format is documented where the schema type
|
||||
is defined.
|
||||
- [x] Warnings/errors never include file *contents* (a track file may hold
|
||||
a private URL with a token) — log paths and error kinds, not bodies.
|
||||
- [x] `cargo clippy` is warning-free; `cargo fmt`, `taplo`, `markdownlint`
|
||||
clean; no `todo!()`/`unimplemented!()` remains.
|
||||
- [x] The whole workspace test suite passes, not just `fsdy`.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Quality gates — help modal (cbd-tui)
|
||||
|
||||
Checklist for the `implement` stage. Each gate is pass/fail and verified by
|
||||
reading the code (the automatic tests live in
|
||||
`cbd-tui/src/app/{bindings,help,mod}.rs` `#[cfg(test)]` modules and must pass
|
||||
via `devenv shell -- cargo test -p cbd-tui`).
|
||||
|
||||
## Single source of truth
|
||||
|
||||
- [x] `bindings::BINDINGS` is the only encoding of key → action → description.
|
||||
`help.rs` contains no hardcoded key names or binding descriptions other
|
||||
than the usage paragraph; key labels come from `bindings::key_label`.
|
||||
- [x] The old `match (app.focus, key.modifiers, key.code)` in `main.rs` is
|
||||
fully replaced by `bindings::lookup` + `App::dispatch`. No key handling
|
||||
remains in `main.rs` besides translating `KeyEvent` → `Action` and
|
||||
honoring `DispatchResult::Quit`.
|
||||
- [x] Behavior parity: every binding that existed before the refactor
|
||||
(main.rs match arms at the pre-change commit) maps to a `BINDINGS` entry
|
||||
triggering the same underlying call. Cross-check arm by arm.
|
||||
|
||||
## Modality
|
||||
|
||||
- [x] While `show_help` is true, only `Scope::Help` entries dispatch; `q`
|
||||
closes the modal and does not quit the app.
|
||||
- [x] `Scope::Help` entries never dispatch while the modal is closed.
|
||||
- [x] The overlay is drawn last in `App::render` and uses ratatui's `Clear`
|
||||
before drawing the popup, so pane content never bleeds through.
|
||||
|
||||
## No panics on user input (AGENTS.md hard rule)
|
||||
|
||||
- [x] All `todo!()` stubs from api-design are gone from `bindings.rs`,
|
||||
`help.rs`, and `App::dispatch`.
|
||||
- [x] `lookup`, `key_label`, `dispatch`, and `help::render` cannot panic for
|
||||
any `KeyEvent` or any frame size (including 1×1); no `unwrap`/`expect`
|
||||
/indexing on user-driven paths. Unknown keys are ignored (`None`), not
|
||||
errors.
|
||||
- [x] Channel send failures in `dispatch` are ignored (`let _ =`), matching
|
||||
the existing module convention — no `unwrap` on `tx.send`.
|
||||
|
||||
## Robust key matching
|
||||
|
||||
- [x] `lookup` ignores `SHIFT` when comparing `KeyCode::Char` chords and
|
||||
requires exact matches for all other modifiers (`Ctrl+n` ≠ `n`).
|
||||
- [x] Key events with `kind != KeyEventKind::Press` are still filtered out
|
||||
before lookup (existing behavior preserved).
|
||||
|
||||
## Code quality
|
||||
|
||||
- [x] Public items in `bindings.rs` and `help.rs` keep doc comments that match
|
||||
the implemented behavior (update them if implementation details shift).
|
||||
- [x] No new external dependencies in `cbd-tui/Cargo.toml`.
|
||||
- [x] `devenv shell -- cargo fmt --check`, `cargo clippy` (no new warnings,
|
||||
no dead-code warnings remaining for `bindings`/`help`), and
|
||||
`cargo test -p cbd-tui` all pass.
|
||||
- [x] The `TODO(api-design)` marker on the `LibraryQueueNext` description is
|
||||
resolved: the wording matches what `MessageFromUi::QueueTracks` actually
|
||||
does server-side.
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# Quality gates: incremental-captures
|
||||
|
||||
Criteria beyond the automatic tests. Each gate is pass/fail by reading the
|
||||
code. Tests live next to the modules they cover (fsdy, capture,
|
||||
capture_store, playback, cbd-tui).
|
||||
|
||||
## Skipped playable (D1)
|
||||
|
||||
- [x] `[playable] skipped = true` parses; `skipped = false`, `skipped`
|
||||
combined with another field, and an empty table are typed
|
||||
`PlayableCardinality`-style errors — never panics.
|
||||
- [x] `from_track` on an `is_skipped` wire track writes a skipped playable
|
||||
(queue persistence and bookmarks keep skipped-ness); `to_track` sets
|
||||
`is_skipped` and keeps the lib path.
|
||||
- [x] `get_urls_for_track` on a skipped file is a typed error, not empty-vec
|
||||
success.
|
||||
|
||||
## Incremental capture (D2)
|
||||
|
||||
- [x] Download captures write into the final folder with no tmp/swap and
|
||||
never delete existing entries; bookmark captures keep tmp-and-swap
|
||||
byte-identically (their existing tests pass unchanged).
|
||||
- [x] Enumeration enforces `max_dirs`/`max_tracks` before any download;
|
||||
the byte budget counts only bytes downloaded this run.
|
||||
- [x] Reuse rule: parseable toml + non-skipped playable (+ existing file for
|
||||
`file` playables) is not re-downloaded; skipped/broken/missing-audio
|
||||
entries are re-captured.
|
||||
- [x] Uncapturable tracks write skipped tomls (no more silent omission);
|
||||
real download failures abort the run but keep prior writes.
|
||||
- [x] Stream URLs and cookies never appear in errors, logs, or progress
|
||||
events (only names, paths, counts).
|
||||
|
||||
## Progress + RPC (D4)
|
||||
|
||||
- [x] `CaptureLibraryNode` replies after validation (name, store enabled,
|
||||
download blessing) with the existing status mapping; the walk runs
|
||||
spawned and reports through `CaptureProgress` updates ending in exactly
|
||||
one `finished` event (success or error).
|
||||
- [x] The progress channel is bounded; a vanished receiver does not abort
|
||||
the capture.
|
||||
|
||||
## Playback (D3)
|
||||
|
||||
- [x] `play` skips `is_skipped` tracks without a provider call and bounds
|
||||
skipping by the queue length at entry: an all-skipped repeat queue stops
|
||||
with a warning instead of spinning.
|
||||
|
||||
## TUI (D5, D7)
|
||||
|
||||
- [x] Skipped tracks render red in queue and library; the current-track
|
||||
marker keeps precedence.
|
||||
- [x] Progress lines show name, done/total, and skipped count; finished
|
||||
lines expire on the render tick; errors render red.
|
||||
- [x] The `W` binding description and the download-capture input label warn
|
||||
that captures can take long and resume by name.
|
||||
- [x] The selected row of a focused pane renders colored items with the dark
|
||||
foreground (readable on the light bar); unfocused panes keep the colored
|
||||
foregrounds.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- [x] New/changed public items documented, stating error/edge behavior.
|
||||
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean; all tests
|
||||
green.
|
||||
- [x] `architecture/incremental-captures.md` reconciled where the
|
||||
implementation diverged.
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# Quality gates — editable and deletable nodes
|
||||
|
||||
Checklist for the `implement` stage. Automatic tests live in
|
||||
`crabidy-core/src/lib.rs`, `tidaldy/src/lib.rs`, and
|
||||
`cbd-tui/src/app/{bindings,mod}.rs` test modules; run with
|
||||
`devenv shell -- cargo test --workspace` (network-dependent tidaldy tests
|
||||
stay `#[ignore]`). 10 tests fail at gate-writing time — they define the
|
||||
target.
|
||||
|
||||
## Contract & wire
|
||||
|
||||
- [x] Proto changes are additive only: existing field numbers untouched;
|
||||
`LibraryNodeChild.is_editable = 5`, `is_deletable = 6`;
|
||||
`RenameLibraryNode` and `DeleteLibraryNode` rpcs present. Old clients
|
||||
keep working against the new server.
|
||||
- [x] `rpc.rs` (server) maps errors per the documented contract for **both**
|
||||
new rpcs: `NotSupported` → `failed_precondition`, `InvalidInput` →
|
||||
`invalid_argument`, everything else → `internal`. No `color-eyre`
|
||||
report or debug formatting of internals leaks into `Status` messages.
|
||||
- [x] `ProviderOrchestrator::{rename,delete}_lib_node` route `/tidal`-prefixed
|
||||
paths to the Tidal client and answer anything else (including the
|
||||
synthetic root `/`) with `NotSupported` — same prefix discipline as
|
||||
`create_lib_node`.
|
||||
- [x] Both new commands follow the existing bounded(1)-reply rendezvous
|
||||
pattern; no new unbounded channels anywhere in the feature.
|
||||
|
||||
## Provider semantics (tidaldy)
|
||||
|
||||
- [x] `rename_lib_node` trims the new title; empty/whitespace →
|
||||
`InvalidInput`; any path that is not a `SearchTerm` (including
|
||||
`/tidal/search` itself) → `NotSupported`; validation happens before any
|
||||
network call.
|
||||
- [x] Rename replaces the term **in place** (keeps its list position);
|
||||
renaming onto an existing term merges (the old slot is removed, no
|
||||
duplicates ever); renaming an unknown term registers the new one
|
||||
(stale-client forgiveness). Returns `get_lib_node(new_path)` — the
|
||||
node at its new percent-encoded path.
|
||||
- [x] `delete_lib_node` accepts only `SearchTerm` paths (`NotSupported`
|
||||
otherwise), removes the term idempotently (unknown term → success),
|
||||
and returns the refreshed `/tidal/search` parent node.
|
||||
- [x] `rename_search_term` / `remove_search_term` follow the established
|
||||
lock discipline: poison-tolerant, the `search_terms` lock is never
|
||||
held across an `await`.
|
||||
- [x] Term children returned by the `Search` arm set
|
||||
`is_editable: true, is_deletable: true`; nothing else in the provider
|
||||
sets either flag.
|
||||
- [x] Queued search tracks keep playing after a rename/delete of their term:
|
||||
`get_urls_for_track` / `get_metadata_for_track` resolve from the track
|
||||
id embedded in the path, independent of term registration.
|
||||
|
||||
## No panics on user input (hard rule)
|
||||
|
||||
- [x] All `todo!()` stubs from api-design are gone (grep the workspace).
|
||||
- [x] Rename/delete failures (network, auth, malformed paths from stale
|
||||
clients) surface as `ProviderError`/`Status`, never a panic; the TUI's
|
||||
rename and delete paths handle an error reply without crashing the
|
||||
orchestrate task (log + stay put).
|
||||
|
||||
## TUI behavior
|
||||
|
||||
- [x] `e` opens the overlay only when the **selected** item `is_editable`,
|
||||
prefilled with the current title; `d` sends `DeleteNode` only when the
|
||||
selected item `is_deletable`; both are silent no-ops otherwise
|
||||
(including when nothing is selected or the list is empty).
|
||||
- [x] The overlay carries its purpose: submit sends `CreateNode` for
|
||||
`InputPurpose::Create` and `RenameNode` for `InputPurpose::Rename`;
|
||||
the rendered label distinguishes them (`new node:` vs `rename:`).
|
||||
While the overlay is open the bindings table stays unreachable
|
||||
(existing input-mode bypass, unchanged).
|
||||
- [x] Delete is deliberately unconfirmed (architecture/node-editing.md D4);
|
||||
the open question about confirmation for higher-value nodes is
|
||||
preserved in the architecture doc, not silently dropped.
|
||||
- [x] Modifiable children are visibly marked in the library list (`[e]`,
|
||||
`[d]` or `[ed]` suffix, `COLOR_SECONDARY`), coexisting with the `[%]`
|
||||
marker for creatable ones.
|
||||
- [x] `RpcClient::rename_library_node` evicts the old path **and** the
|
||||
parent entry, then caches the node under its new path;
|
||||
`delete_library_node` evicts the deleted path and the parent, then
|
||||
caches the returned parent. No stale `/tidal/search` listing can
|
||||
resurrect an old term.
|
||||
- [x] On successful rename the library navigates into the renamed node; on
|
||||
successful delete it shows the refreshed parent; on failure the
|
||||
library stays where it was.
|
||||
|
||||
## Code quality
|
||||
|
||||
- [x] Public items added in all five crates have doc comments matching final
|
||||
behavior; the `e`/`d` bindings appear in the help modal automatically.
|
||||
- [x] No new dependencies.
|
||||
- [x] `devenv shell -- cargo fmt --check`, `cargo clippy --workspace`
|
||||
(no new warnings), `cargo test --workspace` all pass.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Quality gates — progressive queueing
|
||||
|
||||
Criteria for `architecture/progressive-queueing.md`. Automatic tests live in
|
||||
`crabidy-core/src/lib.rs` (default `resolve_tracks_into` walk, fake
|
||||
provider), `crabidy-server/src/lib.rs` (`PendingResolve` chunk application),
|
||||
`tidaldy/src/lib.rs` (offline path validation), and
|
||||
`cbd-tui/src/app/queue.rs` (indicator rendering). They fail while the stubs
|
||||
are unimplemented — that is the target state for `implement`.
|
||||
|
||||
Run: `devenv shell -- cargo test --workspace` (session-local
|
||||
`CARGO_TARGET_DIR` when `target/` is owner-built).
|
||||
|
||||
## Channel and concurrency gates (verify by reading)
|
||||
|
||||
- [x] **All new channels are bounded.** The chunk channel is small (≤ 8
|
||||
chunks); forwarder → playback reuses the existing `bounded(64)`. No
|
||||
`unbounded()` anywhere in the feature.
|
||||
- [x] **No lock is held across an `await`.** The `pending` map and `queue`
|
||||
mutexes are locked, used, and released inside synchronous blocks only
|
||||
— same discipline as the existing playback handlers.
|
||||
- [x] **The playback loop stays the single writer of queue state.** Neither
|
||||
the forwarder task nor the provider resolve task touches
|
||||
`QueueManager` or broadcasts; they only send commands.
|
||||
- [x] **The provider loop never blocks on a resolve.** The `ResolveTracks`
|
||||
arm spawns; `GetTrackUrls` for the first chunk's track must be
|
||||
servable while a resolve is still streaming (deadlock check: playback
|
||||
awaiting `GetTrackUrls` + provider awaiting a chunk send must be
|
||||
impossible).
|
||||
- [x] **Channel semantics are documented where they are the contract**:
|
||||
sender-drop = finished, receiver-drop = cancel, on both
|
||||
`ProviderClient::resolve_tracks_into` and
|
||||
`ProviderCommand::ResolveTracks`.
|
||||
|
||||
## Cancellation gates
|
||||
|
||||
- [x] **`Replace` and `Clear` cancel every pending op** before mutating the
|
||||
queue; `Queue`/`Append`/`Insert` cancel nothing.
|
||||
- [x] **Cancellation propagates to the network fetch**: the forwarder drops
|
||||
the chunk receiver, and both resolve implementations (default walk and
|
||||
tidaldy override) treat a failed chunk send as "stop fetching, return
|
||||
Ok" — verified by test for the default, by reading for tidaldy's page
|
||||
loop.
|
||||
- [x] **Late chunks for finished/cancelled ops are dropped silently** (no
|
||||
error, no queue mutation).
|
||||
|
||||
## Behavior gates
|
||||
|
||||
- [x] **Immediate feedback**: accepting a resolve op broadcasts a `Queue`
|
||||
snapshot with `resolving = true` before the first chunk arrives.
|
||||
- [x] **`resolving` is true iff at least one op is pending**, and every
|
||||
queue broadcast from the playback loop goes through the one helper
|
||||
that sets it (grep: no direct `StreamUpdate::Queue(` construction in
|
||||
the op/chunk paths besides `broadcast_queue`).
|
||||
- [x] **Playback starts with the first chunk** that makes a track current
|
||||
(replace, or landing in an empty queue) and never restarts for later
|
||||
chunks of the same op — covered by the `PendingResolve` tests, plus
|
||||
reading the `apply_resolved_chunk` → `play_if_some` wiring.
|
||||
- [x] **Order preserved end-to-end**: pages arrive in collection order,
|
||||
nodes in listing order (pre-order walk — the old LIFO reversal must
|
||||
not reappear), multi-path requests resolve sequentially in request
|
||||
order, and `InsertAfter` keeps the whole op contiguous.
|
||||
- [x] **An op that finishes with zero tracks logs the existing
|
||||
"resolved to no playable tracks" warning** with enough context (paths).
|
||||
|
||||
## Error and robustness gates
|
||||
|
||||
- [x] **No panics on provider failure, in any spawned task.** Unreadable
|
||||
nodes are skipped with a warning; a dead provider channel ends the op
|
||||
(with `ResolveFinished` still sent, so `resolving` clears) instead of
|
||||
leaving a stuck indicator.
|
||||
- [x] **Tracing spans survive the spawns**: forwarder and provider resolve
|
||||
tasks attribute their events to the originating request's span
|
||||
(`ProviderMessage::new` capture + `in_current_span`/explicit parent).
|
||||
- [x] **tidaldy page loop honors the existing token/refresh path** (reuses
|
||||
`make_request`) and adds no new retry logic.
|
||||
|
||||
## Wire and UI gates
|
||||
|
||||
- [x] **Proto change is additive only**: `Queue.resolving = 4`, no rpc shape
|
||||
changes; field documented in the proto.
|
||||
- [x] **The TUI pseudo-item is render-only**: never in the list model,
|
||||
unreachable by selection/removal/navigation (`get_size` unchanged) —
|
||||
covered by tests.
|
||||
- [x] **Indicator style matches the pane** (`COLOR_SECONDARY`, inside the
|
||||
queue block, after the last track).
|
||||
- [x] **Old client / new server and new client / old server both degrade
|
||||
cleanly** (flag ignored / never set — reasoning check against the
|
||||
generated proto defaults).
|
||||
|
||||
## Documentation gates
|
||||
|
||||
- [x] **`ProviderClient::resolve_tracks_into` docs state the full channel
|
||||
contract** (order, completion, cancellation, error policy).
|
||||
- [x] **Deviations from the architecture are recorded** in
|
||||
`plan/summary.md` under this feature.
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# Quality gates: queue-persistence
|
||||
|
||||
Criteria the implementation must satisfy beyond the automatic tests
|
||||
(`fsdy/src/lib.rs`, `crabidy-server/src/queue_store.rs`, plus the playback
|
||||
and TUI tests added during implementation). Each gate is pass/fail by
|
||||
reading the code.
|
||||
|
||||
## fsdy instances and serialization
|
||||
|
||||
- [x] Every occurrence of the hardcoded `"/fs"`/`"/fs/"` prefix inside
|
||||
`fsdy::Client` methods now derives from the instance's `provider_root`;
|
||||
`PROVIDER_ROOT` remains only as the default instance's constant
|
||||
(`init`) and for external callers.
|
||||
- [x] Path traversal validation still happens in exactly one place
|
||||
(`disk_path`) and applies to every instance.
|
||||
- [x] `TrackFile::from_track` is the single Track→file conversion site,
|
||||
and it always emits a `link` playable (no special cases per provider).
|
||||
- [x] Serialization never panics: `to_toml` returns
|
||||
`TrackFileError::Serialize`, and every `Option` field is
|
||||
skip-serialized (TOML cannot represent `None`).
|
||||
- [x] The one-hop link argument holds in code: `get_urls_for_track` still
|
||||
returns `MalformedPath` for a link playable, so removing the
|
||||
`LinkIntoFs` rejection cannot introduce recursion anywhere.
|
||||
|
||||
## Queue store
|
||||
|
||||
- [x] `QueueStore` is the only writer of the queues directory; the
|
||||
`/queues` provider instance only reads.
|
||||
- [x] Writes are tmp-and-swap: entries are written to a hidden
|
||||
(dot-prefixed) temp sibling, then the old folder is removed and the
|
||||
temp renamed. No code path writes entries into the live folder
|
||||
directly.
|
||||
- [x] `save` validates the name first and never touches disk for an
|
||||
invalid name or empty snapshot.
|
||||
- [x] `load_current` never fails the server: missing folder → `None`,
|
||||
broken entry → skip with a warning naming the file (never its
|
||||
contents), broken sidecar → default state with a warning.
|
||||
- [x] File names come from `fsdy::track_file_name` — no second naming
|
||||
scheme.
|
||||
- [x] No file contents in logs anywhere in the store (paths and names
|
||||
only).
|
||||
|
||||
## Playback wiring
|
||||
|
||||
- [x] The playback loop never blocks on disk: auto-persist goes through
|
||||
the `watch` channel (latest wins), `SaveQueue` writes on a spawned
|
||||
task that reports back through the command's result channel.
|
||||
- [x] Every queue-state change reaches the persist channel: queue
|
||||
content changes (the `broadcast_queue` funnel), current-track changes
|
||||
(`play`), and the shuffle/repeat toggles.
|
||||
- [x] The persister task debounces and skips snapshots equal to the last
|
||||
one written (pure `resolving`-flag broadcasts must not rewrite the
|
||||
folder).
|
||||
- [x] Persist failures are warnings; no persist error can stop playback
|
||||
or crash the loop.
|
||||
- [x] The startup restore runs before the playback loop serves commands,
|
||||
restores tracks + position + repeat/shuffle, and never starts
|
||||
playback (`PlayState::Stopped`).
|
||||
- [x] Restore tolerates a corrupt position (out of range → clamped or
|
||||
reset, never a panic).
|
||||
- [x] A server without a usable queues directory (no config dir, mkdir
|
||||
fails) runs without persistence after a warning — never dies.
|
||||
|
||||
## RPC and orchestrator
|
||||
|
||||
- [x] `save_queue` maps errors: invalid name → `invalid_argument`, empty
|
||||
queue → `failed_precondition`, I/O → `internal`; no `color-eyre`/debug
|
||||
reports leak to clients.
|
||||
- [x] The orchestrator routes `/queues` in **every** `ProviderClient`
|
||||
method (same completeness as `/fs`), and `get_lib_root` lists the
|
||||
`queues` child only when the instance exists.
|
||||
- [x] `/queues` mutations via the library stay `NotSupported`
|
||||
(create/rename/delete unchanged).
|
||||
|
||||
## TUI
|
||||
|
||||
- [x] `w` is bound in `Scope::Queue` only, has a help description, and
|
||||
passes the existing bindings-table invariant tests unchanged.
|
||||
- [x] The save overlay reuses `InputState` (Esc cancels, Enter submits
|
||||
trimmed, empty submit closes silently) and is a no-op while the queue
|
||||
is empty.
|
||||
- [x] `MessageFromUi::SaveQueue` reaches the `SaveQueue` RPC; a failed
|
||||
save must not crash the TUI.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- [x] New public items are documented; docs state error/edge behavior.
|
||||
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||
whole workspace; all tests green.
|
||||
- [x] `architecture/fs-provider.md` reconciled: the "no links into /fs"
|
||||
rule replaced by the one-hop semantics, D2's "chains structurally
|
||||
impossible" wording updated.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue