1772 lines
98 KiB
Markdown
1772 lines
98 KiB
Markdown
# 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.
|
||
|
||
## crabidy content store — the /crabidy provider (2026-07-22)
|
||
|
||
Green-field refactor per `architecture/crabidy-store.md`,
|
||
`quality/crabidy-store.md`, `plan/crabidy-store.md`. Collapsed
|
||
`/queues` + `/bookmarks` + `/captures` into one `/crabidy` fs provider
|
||
whose track tomls link into a content-addressed store that de-duplicates
|
||
audio by provider id and by content hash. No data migration.
|
||
|
||
Built:
|
||
|
||
- **Wire (`crabidy-core`)**: `Track.provider_item_id` + `Track.is_captured`,
|
||
`LibraryNode.is_captured`, `LibraryNodeChild.is_captured`.
|
||
- **`fsdy`**: `Playable::Store` + `PlayableSpec.store` (5-way cardinality,
|
||
`StoreName` validation), `from_track_store`, `Client::with_store_root`
|
||
and store resolution in `get_urls_for_track`; `to_track` marks store
|
||
playables captured. `AlbumMeta` gained `Clone`.
|
||
- **`crabidy-server/src/crabidy_store.rs`** (new): `CrabidyStore` owns the
|
||
state tree (`dirs::state_dir()/crabidy`) and the data store
|
||
(`dirs::data_dir()/crabidy`). `StoreIndex` (provider-id → entry, hash →
|
||
entry) derived by scanning `*.cbd-store.toml` at open, updated on write.
|
||
`save` (Link/Capture) enumerates the source into a temp folder and swaps
|
||
it in atomically (conflict → refuse). `capture_track` runs the D4 flow:
|
||
already-store-backed → reuse; provider-id hit → reuse + alias; else
|
||
fetch (download to temp, or hash the local file) → hash hit → add
|
||
identity + discard bytes; else new store entry. Queue persistence
|
||
(`QueueSnapshot`/`QueueState`/`persist_current`/`load_current`/
|
||
`spawn_persister`) moved here; `save_snapshot` is the queue-`w`
|
||
link-save. Full local-source dedup test suite.
|
||
- **`capture.rs`**: reduced to the primitives the store uses — `enumerate`,
|
||
`Downloader::download_to` (windowed download to a file, returns ext),
|
||
`Progress`, `Caps`. Removed `Sink`/`capture_into`/`write_links`/
|
||
`fetch_track`/`copy_local`/`existing_is_satisfied` and `audio_file_name`.
|
||
`CaptureError` gained `Conflict` and `Store(StoreError)`.
|
||
- **Orchestrator**: one `crabidy_client` + `crabidy_store` replacing the
|
||
three fields; single `crabidy_owns` routing arm across all methods;
|
||
`get_lib_node` calls `annotate_captured` so captured tracks are marked
|
||
in any provider's listing; `crabidy_store()` accessor shares the Arc
|
||
with playback.
|
||
- **`rpc.rs`**: `capture_error_status` helper; `save_queue` now link-saves
|
||
the live queue into `/crabidy`.
|
||
- **`playback.rs`**: persists/restores via `CrabidyStore`.
|
||
- **`cbd-tui`**: `/crabidy/current` path; captured `|` row marker; delete
|
||
confirmation removed (deletes go direct, never touch the store).
|
||
- **Providers**: tidal sets `provider_item_id` to the Tidal track id,
|
||
youtube to the video id.
|
||
- Docs: superseded `bookmarks.md`/`captures.md`/`capture-deletion.md`;
|
||
README config/dirs table, single `crabidy` provider, `|` marker,
|
||
direct deletes.
|
||
|
||
Deviations from the plan/architecture:
|
||
|
||
- **`SaveQueue` RPC kept** (architecture D6 said remove it). Reimplemented
|
||
server-side as a Link save of the live queue into `/crabidy` via
|
||
`CrabidyStore::save_snapshot`; queue-`w` still uses it, queue-`W` uses
|
||
`CaptureLibraryNode(download)` on `/crabidy/current`. Rationale: removing
|
||
it would ripple through the auth role table, `cbd-web`, and the TUI
|
||
client for no behavioural gain — the two paths produce identical
|
||
`/crabidy/<name>` folders.
|
||
- **fs `provider_item_id` left empty** (D3 said canonical path). fs
|
||
de-duplicates by content hash instead; `to_track` has no disk context to
|
||
resolve a relative `file` playable to a canonical path, and hashing a
|
||
local file is cheap. "Already in the store → no-op" still works via the
|
||
store-root path check.
|
||
- **Folder captured-marking is shallow**: `annotate_captured` marks tracks
|
||
via the index and marks a node captured only when all its tracks are
|
||
captured and it has no child nodes (flat saves, e.g. a captured queue).
|
||
Store-backed tracks under `/crabidy` are marked directly by `to_track`.
|
||
A nested save's top folder is not marked (its leaf folders/tracks are) —
|
||
full-recursion marking is future work.
|
||
- **api-design stage** was folded into implementation: for a refactor of
|
||
live code there was no separate compiling-stub milestone; the proto and
|
||
`fsdy` changes landed as real code and `crabidy_store.rs` was stubbed
|
||
(stage 2) then implemented (stage 5).
|
||
- Store **garbage collection** stays out of scope (D10): deleting a toml
|
||
never reclaims store audio, so orphans can accumulate.
|
||
|
||
## The CLI (architecture/cli.md)
|
||
|
||
A comprehensive clap-derive CLI across all three binaries, sharing one
|
||
command surface via the `cbd-cli` crate.
|
||
|
||
### What was built
|
||
|
||
- **`cbd-cli` executor** (`client` feature): `run_remote` dispatches every
|
||
`LibraryCmd`/`QueueCmd`/`GlobalCmd` variant to its gRPC call, mirroring
|
||
`cbd-tui`'s `RpcClient` request construction (with a direct `Stop` call,
|
||
which had no wrapper). Listings and the queue print human-readably;
|
||
captured rows are marked `*`. The endpoint has a 5 s connect timeout and
|
||
a 30 s per-request deadline, so a CLI never hangs. Transport/`Status`
|
||
errors map to a single-line message (no color-eyre chain).
|
||
- **Config writers**: `ServerSettings::store` round-trips `[auth]`
|
||
(`skip_serializing_if` keeps the flat shape parseable under
|
||
`deny_unknown_fields`); `cbd-tui`'s `config` module gained
|
||
`load_first_run`, `apply_overrides`, `store`, and `write_auth` (plus
|
||
path-based `_at` variants for tests).
|
||
- **`crabidy-server`**: `main` now parses `cbd_cli::ServerCli`; a new
|
||
`crabidy_server::cli` module holds `guard`, `scan`, `scan_dir`,
|
||
`write_role_hash`, and `connection`. `guard` prints the PHC hash to
|
||
stdout (pipeable) and the confirmation to stderr. `scan` walks a folder
|
||
(bounded, hidden/symlink-skipped) writing `.cbd-track.toml` sidecars;
|
||
`--capture`/`--move` use the new `CrabidyStore::ingest_file`.
|
||
`hash-password` was removed (`guard <role> --no-config` replaces it).
|
||
- **`CrabidyStore::ingest_file`**: the local-source half of
|
||
`capture_track`, factored out — hash, de-dup by content hash, copy or
|
||
move into the store, write the sidecar, return the store name.
|
||
- **Clients**: `cbd-tui` and `cbd` parse their `cbd_cli` CLIs; the
|
||
no-subcommand path loads the TOML config (defaults written on first
|
||
run) and applies the flag overrides, then runs as before. `auth` writes
|
||
the client config; `library`/`queue`/`global` call `run_remote`.
|
||
- **Assets**: each binary crate has a `build.rs` that build-depends on
|
||
`cbd-cli` (default features only) and writes completions + a man page
|
||
into `OUT_DIR`, and into `$CBD_ASSET_DIR` when set. A devenv
|
||
`gen-cli-assets` script produces `dist/completions/**` and `dist/man`.
|
||
|
||
### Deviations
|
||
|
||
- **ClapSerde kept, not replaced.** Architecture D2 floated replacing
|
||
ClapSerde with plain serde. Instead the `Config` still derives
|
||
`ClapSerde` (so `Config::default()`/`Opt` load the file), and the new
|
||
`config` helpers read/write it *without* `merge_clap` — argv is parsed
|
||
by `cbd_cli::TuiCli`/`CbdCli` and applied via `apply_overrides`. This
|
||
keeps the on-disk schema byte-identical to what `init_config` wrote
|
||
(verified: the file is `[server]`-nested; the README example was
|
||
corrected to match).
|
||
- **Connection flags are top-level, not clap-`global`.** They must come
|
||
before the subcommand (`cbd-tui --address X queue show`). Making them
|
||
`global=true` would collide with `auth`'s own `--address`. Documented
|
||
in the README.
|
||
- **`Box<dyn Error>` for CLI reports**, not color-eyre, following the
|
||
existing binaries' convention; errors are printed as one line with a
|
||
non-zero exit.
|
||
- **No fake-tonic-server test.** Coverage is argument-parse tests
|
||
(`cbd-cli`), config-writer round-trips (`settings`, `cbd-tui::config`),
|
||
and `scan`/`ingest_file` behaviour (`crabidy_server::cli`,
|
||
`crabidy_store`). The unreachable-server error path was verified
|
||
manually (concise message, exit 1).
|
||
|
||
## fyyd podcast provider (2026-07-23)
|
||
|
||
New `/fyyd` provider (crate `fyyd`) for finding and playing podcasts via
|
||
fyyd's keyless public API. Ran the full dev-flow pipeline; artifacts:
|
||
`architecture/fyyd-provider.md`, `quality/fyyd-provider.md`,
|
||
`plan/fyyd-provider.md`. Modelled on `ytdy` (remote, search-driven,
|
||
in-memory terms), wired into `ProviderOrchestrator` with the same
|
||
five-place pattern as every other provider: settings toggle, an
|
||
owns-check plus `fyyd_provider()`, a `build()` block, a root child, and
|
||
eight dispatch arms. No proto change, no new `ProviderCommand`, and no
|
||
audio-player change:
|
||
episode `enclosure` URLs feed straight into the existing HTTP-streaming
|
||
path.
|
||
|
||
Where the implementation shaped decisions beyond the plan:
|
||
|
||
- **Extra tree level (the defining difference from `/youtube`).** A
|
||
podcast search returns *podcasts*, each a container of *episodes*, so
|
||
the tree is `search-term → podcast → episodes-as-tracks` (one level
|
||
deeper than YouTube). A podcast node maps to ytdy's *playlist* (a
|
||
queueable/downloadable container of tracks); a search-term node maps to
|
||
ytdy's *playlists* listing (containers as children). The `search` and
|
||
`hot` branches share the podcast-node and episode-leaf builders.
|
||
|
||
- **`/fyyd/hot` added.** Not in the original one-line request, but fyyd's
|
||
`/feature/podcast/hot` gives a zero-typing browse for free, so the root
|
||
exposes `search` (creatable) **and** `hot` (fixed). Documented as D3.
|
||
|
||
- **HTTP behind a `Fyyd` trait** (`fyyd/src/api.rs`), faked in tests, so
|
||
all 10 provider unit tests run with no network — same seam pattern as
|
||
ytdy's `Extract`. Production `FyydApi` uses the workspace `reqwest`
|
||
(json/query/rustls), unwraps fyyd's `data` envelope, and decodes DTOs
|
||
defensively (`#[serde(default)]`, drop id-less entries, non-positive
|
||
durations → `None`).
|
||
|
||
- **Artist backfill.** A track's `artist` is the podcast title. Listing
|
||
episodes under a podcast already yields the title, so listed tracks get
|
||
it free; a *directly* fetched episode (`/episode`) carries no podcast
|
||
title, so `FyydApi::episode` does one extra best-effort `/podcast`
|
||
lookup (failure → `None`, never fatal). D4.
|
||
|
||
- **Non-fatal init, no credentials.** Unlike tidal, a missing `fyyd.toml`
|
||
is normal and a build failure only drops `/fyyd`. Every call is
|
||
timeout-bounded and every listing capped (`search_results`,
|
||
`hot_count`, `episodes_per_podcast`).
|
||
|
||
**Live validation done (2026-07-23).** All four `api.fyyd.de` endpoints
|
||
were hit directly and match the `FyydApi` DTOs exactly (the `data`
|
||
envelope; numeric podcast `id`+`title`; `/podcast/episodes` as a single
|
||
object with `title`+`episodes[]`; episode `id`/`title`/`enclosure`/
|
||
`duration`/`podcast_id`). No DTO change was needed. Remaining open: a
|
||
manual audio-playthrough + `W`-capture smoke test on the running server
|
||
(enclosures are plain media URLs, some via podtrac redirects, which both
|
||
the resolve path and the capture reqwest client follow).
|
||
|
||
Verification: `fyyd` 10 tests + `crabidy-server` 74 lib + 4 integration
|
||
green; clippy and rustfmt clean on both crates.
|
||
|
||
## audiobookshelf provider — `/abs` (2026-07-23)
|
||
|
||
New `absdy` crate mounted at `/abs`, browsing/searching/playing audiobooks
|
||
from a self-hosted audiobookshelf (ABS) server. Built end-to-end from the
|
||
architecture doc; the design was grounded live against the test server
|
||
before any code. All plan/audiobookshelf-provider.md tasks (T1–T11) done.
|
||
|
||
**Followed the fyyd shape closely** — an `Abs` reqwest seam (`api.rs`)
|
||
faked in tests so all 14 provider unit tests run with no network, an
|
||
in-memory search-term store, a `library → book → tracks` tree with a
|
||
per-library `search` subtree, and the same central "download blessing".
|
||
|
||
**Deviations / ABS-specific decisions (vs the fyyd template):**
|
||
|
||
- **Credentials, and a secret.** Unlike fyyd, ABS needs a `base_url` +
|
||
`api_key`; a missing/incomplete `abs.toml` disables `/abs` non-fatally
|
||
(like fyyd/youtube on a failed probe), it does *not* stay fatal like
|
||
tidal. The `api_key` and the `?token=` stream URL are secrets:
|
||
`Settings` and `AbsApi` have **manual redacting `Debug`**, and the token
|
||
is built only in `Abs::stream_url` — never logged, never handed to a
|
||
`reqwest` call inside `absdy` (browse auth is a bearer header). Gates
|
||
G1–G3.
|
||
|
||
- **Playback needs no API call.** A track's stream URL is fully derivable
|
||
from its path (`/api/items/<item>/file/<ino>?token=<key>`), so
|
||
`get_urls_for_track` builds it directly. Verified live: `?token=` auth
|
||
returns 200 and the endpoint honors HTTP range (206), so the existing
|
||
windowed-HTTP player streams it. D4/G12.
|
||
|
||
- **Per-library search terms.** ABS search is per-library, so the term
|
||
store is keyed by library id (`HashMap<lib, Vec<String>>`), not fyyd's
|
||
single namespace. The reserved `search` segment splits the search branch
|
||
from item ids (UUIDs never equal `search`).
|
||
|
||
- **Queueability from the summary.** A book child's queueable flag comes
|
||
from the item summary's `numAudioFiles` (no open needed), so ebook-only
|
||
items are shown but not queueable/capturable. Root lists only `book`
|
||
libraries (podcast libraries out of scope, D6).
|
||
|
||
- **`get_lib_root` is a placeholder.** Listing ABS libraries needs a
|
||
network call, but the trait's `get_lib_root` is sync; the real `/abs`
|
||
root is served by the async `get_lib_node`. The orchestrator only builds
|
||
the global-root link from `PROVIDER_ROOT`, so this is invisible.
|
||
|
||
**Live validation done (2026-07-23).** `absdy/tests/live.rs` (`#[ignore]`,
|
||
env-gated) hit the real server end-to-end — libraries → items → search →
|
||
detail — and the `AbsApi` DTOs decode the live JSON with no change needed
|
||
(`media.metadata.{title,authorName}`, `media.numAudioFiles`,
|
||
`media.tracks[].{ino,title,duration}`, the `{ "book": [ { libraryItem } ] }`
|
||
search envelope). Remaining open: a manual audio-playthrough + `W`-capture
|
||
smoke test on the running server with a configured `abs.toml`.
|
||
|
||
Verification: `absdy` 14 tests + `crabidy-server` 12 (+ existing) +
|
||
`crabidy-core` 77 green; clippy and rustfmt clean on `absdy` and
|
||
`crabidy-server`.
|
||
|
||
## opus playback (2026-07-24)
|
||
|
||
Fixed "can't play opus files from the abs server". Root cause: rodio decodes
|
||
via symphonia 0.5, which ships **no Opus decoder**, and the abs provider serves
|
||
raw `.opus` files — so `Decoder::build()` failed on them (every other format
|
||
worked). Client-side fix, so it covers opus from **any** provider:
|
||
|
||
- **`audio-player/src/opus_source.rs`** (new): `OpusSource`, a rodio `Source`
|
||
that demuxes Ogg with symphonia's own Ogg reader and decodes Opus via
|
||
**`symphonia-adapter-libopus`** (libopus, registered into an explicit codec
|
||
registry — rodio's `Decoder` won't take a custom registry, so opus is driven
|
||
directly). Mirrors rodio 0.22's `SymphoniaDecoder` loop (channels/rate/
|
||
duration/seek). First packet decoded up front so the struct always holds a
|
||
real spec (symphonia's `SampleBuffer::new` divides by channel count).
|
||
- **`player_engine.rs`**: `open_source` refactored into a shared `build_source`
|
||
that sniffs the first 64 bytes (`OggS`+`OpusHead`), rewinds, and routes opus
|
||
to `OpusSource`, everything else to rodio. Shared across the http + file
|
||
paths. Content-sniffing (not the extension hint) is essential — the abs
|
||
stream URL has no extension (the ino is a number).
|
||
- **`devenv.nix`**: `cmake`+`ninja` (bundled libopus builds with CMake).
|
||
|
||
**Verified end-to-end** with ffmpeg-encoded fixtures: mono opus → 48 kHz, 2.02 s
|
||
duration, correct sample count; stereo opus → 2ch/48 kHz, and **seek to 1 s of a
|
||
3 s file** left ~2.02 s. fmt/clippy clean, 11 audio-player tests pass,
|
||
`crabidy-server` builds (public API unchanged).
|
||
|
||
## soundcloud provider — dev-flow S1–S4 + implement Phase A (2026-07-24)
|
||
|
||
Ran the dev-flow for a `/soundcloud` provider (streamrip referenced for the
|
||
API). Decisions confirmed with the user: (1) root children served sync from
|
||
`get_lib_root`; (2) hand-written m3u8 parser (no dep); (3) `scrape_client_id`
|
||
free fn; (4) search lists tracks **and** playlists; playback via a new
|
||
`HlsStream`; client_id config-or-scrape with re-scrape-on-401; **login
|
||
optional** (OAuth token unlocks likes/playlists, else public-only).
|
||
|
||
- **S1 architecture** `architecture/soundcloud-provider.md` (D1–D7, 2 diagrams).
|
||
- **S2 api-design** `soundclouddy` crate stubs (`Sc` seam, `Client`,
|
||
`ScPath`+real `parse_path`) + `audio-player/src/hls.rs` (`HlsStream` skeleton).
|
||
- **S3 quality-gates** `quality/soundcloud-provider.md` (G1–G23) + acceptance
|
||
tests.
|
||
- **S4 task-plan** `plan/soundcloud.md` (phases A–E, each mapped to a gate/test).
|
||
- **S5 implement — Phases A–E built**:
|
||
- **A** provider tree/path logic (`lib.rs`) over the `Sc` seam.
|
||
- **B** `ScApi`: reqwest client with per-call timeout + browser UA, signed GET
|
||
with **re-scrape-on-401** (once), pure scrape parsers
|
||
(`extract_app_version`/`extract_script_urls`/`extract_client_id`, unit-tested
|
||
against fixtures), defensive wire DTOs, `resolve_stream_url` (picks
|
||
hls+audio/mpeg → media m3u8), batched `hydrate_tracks`, and `init` that
|
||
scrapes if needed and persists the id.
|
||
- **C** `audio-player/src/hls.rs` `HlsStream` (`SourceStream`): fetches the
|
||
m3u8, follows one master level, streams mp3 segments in order; forward-only,
|
||
length-less, non-seekable. `parse_playlist` unit-tested. `open_source` routes
|
||
`.m3u8` → HlsStream (non-seekable so symphonia never end-seeks); `build_source`
|
||
gained a `seekable` flag.
|
||
- **D** server wiring: `soundclouddy` dep, `settings.rs` (`ALL_PROVIDERS`→8,
|
||
`ProviderToggles.soundcloud`), `provider.rs` (`sc_client`, `sc_owns`,
|
||
`sc_provider`, non-fatal `build` block, root child, all 8 dispatch arms).
|
||
- **E** `soundclouddy/tests/live.rs` (`#[ignore]`, env-gated); fmt/clippy/machete
|
||
clean; dropped unused `serde_json`.
|
||
|
||
**Verified offline:** soundclouddy 19 tests + 4 api-parser tests, audio-player 14
|
||
(incl. 3 HLS-parser), crabidy-server 77+4 — all green; `cargo build -p
|
||
crabidy-server` clean. **Needs live SoundCloud to confirm (no creds/audio in
|
||
sandbox):** the actual `client_id` scrape, live API JSON shapes, and mp3-HLS
|
||
play-to-EOS — all exercised by `tests/live.rs` and the `#[ignore]` gates
|
||
(quality G7/G8/G14/G19). Run those on a real machine.
|
||
|
||
## jamendo provider — `/jamendo` (2026-07-24)
|
||
|
||
Ran the full dev-flow pipeline autonomously for a `/jamendo` provider (free /
|
||
Creative-Commons music via the official `api.jamendo.com/v3.0`). Artifacts:
|
||
`architecture/jamendo-provider.md` (D1–D6, 2 diagrams), `quality/jamendo.md`
|
||
(T1–T11 + G1–G10), `plan/jamendo.md`. New crate **`jamendody`**, shaped on
|
||
`soundclouddy` but the *simple* case — Jamendo has a stable official API, so
|
||
there is **no `client_id` scraping, no OAuth, and no HLS**.
|
||
|
||
- **Tree**: `/jamendo` → `search` (creatable) → `/jamendo/search/<term>` listing
|
||
tracks (canonical `/jamendo/track/<id>` rows) + albums (canonical queueable
|
||
`/jamendo/album/<id>` containers) → `/jamendo/album/<id>` tracks. Same
|
||
search-term store + download-blessing pattern as the other providers.
|
||
- **Playback with no player change**: `get_urls_for_track` returns the track's
|
||
direct `audio` MP3 URL, played on the existing windowed-HTTP + symphonia path.
|
||
- **`Jam` reqwest seam** (`api.rs`, faked in tests): `search_tracks`,
|
||
`search_albums`, `album_tracks`, `track_detail`, `track_stream`. Per-call
|
||
timeout, `limit` clamped to Jamendo's 200, `client_id` redacted from `Debug`,
|
||
defensive `#[serde(default)]` DTOs over the `{headers, results}` envelope
|
||
(HTTP-200 error envelopes mapped to typed `FetchError`).
|
||
- **Duration stays in seconds** — Jamendo's native unit *is* `Track.duration`'s,
|
||
so it passes through unchanged (a test guards against a ms/seconds regression).
|
||
- **Server wiring** (the standard pattern): `jamendo` in `ALL_PROVIDERS` (8→9),
|
||
`ProviderToggles.jamendo`; `jamendo_client` field, `jamendo_owns`,
|
||
`jamendo_provider`, a non-fatal `build()` block (missing/empty `client_id`
|
||
disables `/jamendo` only), a root child, and dispatch arms in all methods
|
||
(`is_track_path`, `get_lib_node`, `get_urls_for_track`,
|
||
`get_metadata_for_track`, `resolve_tracks_into`, create/rename/delete).
|
||
|
||
Deviations: stream URL is a dedicated `track_stream` seam method (not a field on
|
||
`JamTrack`), matching soundcloud's metadata/URL separation; `client_id` required
|
||
with **no init-time network** (a bad key surfaces lazily as `FetchError`);
|
||
`audioformat` defaults to `mp32` (higher bitrate) over Jamendo's `mp31`.
|
||
|
||
**Verified offline:** `jamendody` 16 tests (T1–T11 + 3 DTO-decode) green,
|
||
`crabidy-server` tests green (settings iterate `ALL_PROVIDERS`, no edit needed),
|
||
`cargo build -p jamendody -p crabidy-server` clean, clippy `-D warnings` + fmt
|
||
clean. **Deferred live gate (R1):** with a real `client_id` in `jamendo.toml`,
|
||
browse → queue → play a track and confirm the `audio` MP3 streams with a correct
|
||
seek bar and clean EOS. Needs network + a registered key; not runnable here.
|
||
|
||
## TUI visual mode (2026-07-24)
|
||
|
||
Full dev-flow run for a vim-style **visual (paint-select) mode** in the library
|
||
pane (architecture/visual-mode.md, quality/visual-mode.md, plan/visual-mode.md).
|
||
`v` or `V` enters it (both the same); movement then toggles the mark of every row
|
||
swept over, so a run is selected by `v` then moving. A second `v`/`V` or `Esc`
|
||
leaves it, marks kept.
|
||
|
||
- **bindings.rs**: new `Action::LibraryVisualMode` bound to `v` (NONE) and `V`
|
||
(SHIFT) in the Library scope; the spectrum toggle **moved off `v` to Global
|
||
`f`** ("frequency") to free the key. Help text/labels derive from `BINDINGS`
|
||
unchanged.
|
||
- **library.rs**: a `visual: bool` with `is_visual`/`toggle_visual`
|
||
(enter toggles the current row, vim-style)/`exit_visual`, `selected_view`, and
|
||
`paint_between(from_view, to_view)` — toggles the mark of every view index in
|
||
the half-open sweep `(from, to]`, mapped through the `/` filter and gated on
|
||
`is_queable` exactly like `s`. `toggle_mark` refactored onto a shared
|
||
`toggle_mark_view`. Title shows `— VISUAL` while active.
|
||
- **mod.rs**: `App::dispatch` captures `was_visual`, auto-leaves visual mode for
|
||
any action except the six movements, `LibraryVisualMode`, and `ClearSearch`;
|
||
the six movements route through a `library_move` helper that paints the swept
|
||
range when visual is active; `Esc` in visual leaves the mode without clearing
|
||
the `/` filter; node/focus changes leave it.
|
||
|
||
Decisions/deviations: **paint is anchored** — the selection is the contiguous
|
||
range `[anchor, cursor]`, and a move toggles the rows whose range membership
|
||
changed, so moving back cleanly reverses (a first per-step-toggle attempt
|
||
stranded the turnaround row — fixed); **library-only** — the queue has no marks
|
||
yet (a standing `queue.rs` FIXME), so `v`/`V` are unbound there (D5); **spectrum
|
||
key `f` is a chosen default**, trivially changed; **web-client parity deferred**
|
||
(D6). Jump moves (`g`/`G`, `Ctrl-d`/`Ctrl-u`) reconcile the whole span.
|
||
|
||
Verification: `cbd-tui` 89 tests green (14 new: 2 binding + 12 dispatch/paint
|
||
covering enter/step/jump/back-sweep/exit/Esc/non-move-exit/node-focus-exit/
|
||
is_queable/filter); clippy `-D warnings` and fmt clean; markdownlint clean. Not
|
||
exercised: live in-terminal keypresses (no TTY here) — the pure dispatch/table
|
||
logic is fully unit-tested and the render path compiles.
|
||
|
||
## build features (2026-07-25)
|
||
|
||
Guarded each provider, Opus decoding, the spectrum, and desktop
|
||
notifications behind Cargo features (all default-on) so people can build a
|
||
non-bloated binary, with the dependency graph aligned to the flags. Ran the
|
||
full dev-flow: `architecture/build-features.md` (D1–D11),
|
||
`quality/build-features.md` (G1–G30), `plan/build-features.md` (A/B/C).
|
||
Three commits.
|
||
|
||
**A — mount registry** (`crabidy-server/src/provider.rs`). The orchestrator
|
||
held nine `Option<Arc<ConcreteClient>>` fields and repeated the same
|
||
`if …_owns(path)` chain across eight `ProviderClient` methods.
|
||
`ProviderClient` is dyn-compatible (`init` carries `Self: Sized`), so it now
|
||
holds `mounts: Vec<Mount>` of `Arc<dyn ProviderClient>` and every method is
|
||
one `owner(path)` lookup — ~600 lines of repetition gone, and a provider is
|
||
named in exactly one place, which is what made the feature work tractable.
|
||
Ordering (crabidy first, orphans last, rest alphabetical) moved to
|
||
`from_mounts`; the five config-file providers share a `mount_from_config`
|
||
helper. Behaviour-preserving, landed on its own with 6 new unit tests
|
||
(ownership boundaries, dispatch, typed errors for unowned paths, ordering,
|
||
the empty registry, dyn dispatch of an overridden `resolve_tracks_into`).
|
||
|
||
**B — the features.** `crabidy-server`: `tidal`, `youtube`, `fyyd`, `abs`,
|
||
`soundcloud`, `jamendo`, `fs`, `opus`, `spectrum`, `web-ui`, plus
|
||
`all-providers`; `cbd` forwards all of them and adds `notifications`;
|
||
`cbd-tui` owns `notifications`; `audio-player` owns `opus`. Gated deps are
|
||
`optional = true`, and the three feature-carrying local crates get
|
||
`default-features = false` in `[workspace.dependencies]` (a member cannot
|
||
drop an inherited default).
|
||
|
||
**Answers to the two questions in the request:** `scan` *did* already treat
|
||
`.opus` as playable; that entry is now conditional on `opus`, so a build
|
||
that cannot decode Opus will not index it either. And `fs` off does take
|
||
`/crabidy` and `/orphans` with it.
|
||
|
||
Decisions taken autonomously (dev-flow, no questions):
|
||
|
||
- **A feature must pay for itself in dependencies.** That is why `crabidy`
|
||
and `orphans` are *not* separate features (no dependency of their own —
|
||
`crabidy_store`/`capture`/`orphans` are written against `fsdy`), and why
|
||
`hls.rs`, `windowed_http.rs`, and `spectrum_tap.rs` stay unconditional.
|
||
Finer-grained control already exists at runtime in the `providers` list.
|
||
- **`fs` is one coherent unit**: `/fs`, the content store, `/crabidy`,
|
||
`/orphans`, bookmarks/captures, queue persistence, and `scan`. Without it
|
||
`CaptureLibraryNode`/`SaveQueue` answer `Unimplemented` and `scan` reports
|
||
the missing feature — no panics, no hangs.
|
||
- **`[auth]`/argon2 is deliberately not gated** (D9): a build that ignored
|
||
configured role hashes would run an intended-to-be-locked server open.
|
||
Refused as a fail-open hole for a small dependency.
|
||
- **The compile-time set bounds the runtime one**: `BUILT_IN_PROVIDERS`
|
||
filters `provider_enabled`, the auto-written default config lists only
|
||
built-ins, and a `providers` entry this binary lacks logs one warning
|
||
(fail-open, unlike auth). `crabidy-server features` / `cbd features` print
|
||
the compiled set and startup logs it.
|
||
- **An internal `_any-provider` marker feature** (every provider enables it)
|
||
expresses "is any provider compiled in?", which Cargo features cannot;
|
||
it keeps the legal-but-degenerate zero-provider build warning-free without
|
||
blanket `allow`s.
|
||
- **`flake.nix` had to change in the same breath**: its native package
|
||
passed a bare `--no-default-features` (meaning "all but `web-ui`"), which
|
||
after this work would have shipped a provider-*less* binary. Now
|
||
`--features all-providers,opus,spectrum,notifications`.
|
||
|
||
Verification: `devenv shell -- check-features` (new script) runs the curated
|
||
matrix — defaults, `--no-default-features`, each of the seven providers
|
||
alone, each extra dropped, the two documented examples, `audio-player`,
|
||
`cbd-tui`, `cbd` — all clippy-clean under `-D warnings`, with tests at both
|
||
extremes. Full default suite green (88 server + 90 tui + the rest). **G1
|
||
verified mechanically**: the default `cargo tree -p crabidy-server` set is
|
||
identical to the pre-change tree. Confirmed by inspection of `cargo tree`
|
||
that `--no-default-features` drops every gated crate, `opus`-off drops
|
||
`opusic-sys`/`symphonia-adapter-libopus`, and `cbd-tui
|
||
--no-default-features` drops `notify-rust`. Smoke-ran both the
|
||
provider-less and the `fs,opus` binaries under isolated XDG dirs: startup
|
||
logs the feature list, writes a default config listing only built-ins, and
|
||
warns once per unavailable name. Not exercised: `nix build` (not run here)
|
||
and playback of an Opus file in an `opus`-less build (no audio device).
|
||
|
||
## queue register (2026-07-26)
|
||
|
||
Marks and visual mode for the queue pane, paired with a vim-style register:
|
||
`y` yanks, `d`/`c`/`C` fill it as they remove, `p`/`P` paste it back after
|
||
or before the cursor. Both clients, in one change. Ran the full dev-flow:
|
||
`architecture/queue-register.md` (D1–D10), `quality/queue-register.md`
|
||
(G1–G26), `plan/queue-register.md`.
|
||
|
||
**No server work at all** — that was the feasibility finding that made this
|
||
cheap: `Remove` already accepted many positions and `Insert` already took a
|
||
path list, so the register reduces to a `Vec<String>` and the whole feature
|
||
is two clients.
|
||
|
||
**Design decisions taken in discussion with the user** (all four of my
|
||
recommendations were accepted):
|
||
|
||
- **Only `y`/`d`/`c`/`C` write the register.** The user's first proposal had
|
||
a library selection fill it implicitly, which keeps `p` backward
|
||
compatible — I argued against it because `s` in the library would then
|
||
silently clobber a clipboard you were about to paste, and the one thing
|
||
that makes vim registers safe is that only explicit yank/delete write
|
||
them. The cost lands on one flow only (browse→insert-here is now `y` then
|
||
`p`); `a`/`L`/`Enter` are untouched.
|
||
- **`p` after the cursor, `P` before**, because after a delete the cursor
|
||
sits on the successor, so paste-after lands one slot late — `P` is the
|
||
exact restore.
|
||
- **`c`/`C` fill the register**, the destructive ops most worth undoing.
|
||
- **One unnamed slot**, with `Register` shaped so named registers stay
|
||
additive.
|
||
|
||
**The one hard problem** was that queue marks are positional while the queue
|
||
is server-pushed and rebuilt on every change (append, playback advancing,
|
||
each streaming-resolve chunk). Naive index remapping silently retargets a
|
||
mark, so `d` would delete the wrong tracks. `carry_marks()` carries marks
|
||
across a snapshot by a greedy in-order match on track path; irreconcilable
|
||
snapshots clear rather than guess, and positions handed to `Remove` are
|
||
always read off the newest list. Ten test cases pin it in the TUI and the
|
||
same cases again in the web client, so the two cannot drift.
|
||
|
||
**Structure:** the library's mark/visual code moved into a `MarkedPane`
|
||
trait in `cbd-tui/src/app/list.rs` (beside the existing `StatefulList`),
|
||
which both panes implement — the library keeps `is_queable` as its mark
|
||
gate, the queue allows every row. The web client has no owned queue list
|
||
(just a cursor), so its marks live on `QueueCursor` beside the server
|
||
signal; same rule, different home, as the architecture doc says.
|
||
|
||
**Deviations from the plan:** commits A and B were merged — group A leaves
|
||
`carry_marks`/`Register` unwired, and committing dead code so the next
|
||
commit can use it is worse than one larger commit. Also removed
|
||
`Library::queue_insert` (orphaned once `p` stopped pulling from the library)
|
||
and rebalanced the help modal's columns: it was already overflowing at 46
|
||
rows in a single column and the seven new bindings made it worse. It still
|
||
truncates below ~43 rows, now pinned by a test rather than hidden —
|
||
scrolling remains the real fix and is still an open question in the
|
||
help-modal design.
|
||
|
||
Verified: cbd-tui 117 tests (was 91), cbd-web 20 (was 12), clippy clean for
|
||
cbd-tui and for cbd-web on **both** native and wasm32 (`mod app` only
|
||
compiles for wasm, so native clippy alone proves nothing), the trunk bundle
|
||
builds, and the book builds. Not exercised: live keypresses in a terminal
|
||
and in a browser — the pure state machines are unit-tested and both render
|
||
paths compile.
|
||
|
||
## rss provider (2026-07-26)
|
||
|
||
`/rss`: podcast feeds you subscribe to by URL, including premium
|
||
per-subscriber feeds, with listings that are never cached. New `rssdy` crate
|
||
plus wiring. Full dev-flow: `architecture/rss-provider.md`,
|
||
`quality/rss-provider.md` (G1–G21), `plan/rss-provider.md`.
|
||
|
||
**Two findings drove the design.**
|
||
|
||
*A premium feed URL is the credential.* Library paths are displayed, logged,
|
||
and persisted into saved queues and bookmark tomls — so a URL in a path would
|
||
leak into all of them. Hence subscriptions are `(name, url)` in `rss.toml` and
|
||
paths carry a name slug plus `blake3(guid)[..16]`:
|
||
`/rss/the-economist-podcasts/676f8bfa48c9cac3`. URLs are redacted from every
|
||
`Debug` impl and never reach an error message (reqwest errors go through
|
||
`without_url`).
|
||
|
||
*"Not cached" has a client-side half.* Both clients cache library listings by
|
||
path, and only `/crabidy`, `/fs`, `/orphans` bypassed it — so without adding
|
||
`/rss` to `MUTABLE_ROOTS` in both, a re-visit would answer from the client's
|
||
cache and server freshness would be invisible. A listing always fetches; a
|
||
listing-written memo (read only when resolving a track, bounded to 8 feeds)
|
||
keeps queueing 40 episodes at one fetch instead of 41, with no TTL to guess
|
||
at.
|
||
|
||
**Verified against the real feed, which caught a bug no unit test would
|
||
have.** `feed-rs` parses `<itunes:duration>` with an NPT parser, and NPT has
|
||
no `MM:SS` form: for `53:25` its fallback regex takes the leading number, so a
|
||
53-minute episode came back as **53 seconds** (`1:20:40` happens to parse
|
||
fine). The iTunes spec allows `S`, `MM:SS`, `HH:MM:SS`, so that one field is
|
||
now recovered from the raw body ourselves — a shallow scan keyed by guid and
|
||
enclosure URL, overriding feed-rs — and the live feed reports 3205 s / 2830 s
|
||
/ 1662 s, matching 53:25 / 47:10 / 27:42.
|
||
|
||
Other decisions: `%` on `/rss` takes a pasted URL, fetches it once, names the
|
||
subscription from the feed's own title and persists it (`e` renames, `d`
|
||
unsubscribes and touches no audio); newest-first ordering enforced at the
|
||
provider boundary, not just in the parser, so any backend obeys it; a separate
|
||
crate from `fyyd` (discovery vs subscription differ in config, identity and
|
||
caching); behind a default-on `rss` cargo feature like every other provider.
|
||
|
||
Bounded by design: per-request timeout, an 8 MiB body cap enforced *while*
|
||
reading chunks rather than after, and an episode cap — a malformed entry is
|
||
skipped, only an unfetchable feed errors, and it fails that node alone.
|
||
|
||
Verified: 25 rssdy tests, 94 crabidy-server, 118 cbd-tui, 20 cbd-web, the
|
||
whole `check-features` matrix (now including `rss` alone) clippy-clean under
|
||
`-D warnings`, fmt clean, book builds, and a live fetch of the user's own
|
||
premium Economist feed. Not exercised: playing an episode through an audio
|
||
device.
|
||
|
||
## seek within a track (2026-07-26)
|
||
|
||
`architecture/seek.md` → `quality/seek.md` → `plan/seek.md`. `Ctrl-b` /
|
||
`Ctrl-f` move the playing position 15 seconds, from the TUI, the browser, and
|
||
`cbd global seek <SECONDS>`.
|
||
|
||
Almost all of it was wiring: `PlayerEngine::seek_to` and its command already
|
||
existed and **nothing called them** — no RPC, no playback command, no binding.
|
||
|
||
*The one real decision was where the arithmetic lives.* A seek is relative
|
||
("15 seconds back") but the engine seeks to an absolute position, so either the
|
||
client computes the target from the last `TrackPosition` it received, or it
|
||
sends a delta and the engine adds it to the live position. The delta wins on
|
||
the ordinary case of pressing the key twice: positions are broadcast on a
|
||
250 ms tick and then cross the network, so three quick presses all read the
|
||
*same* stale base and jump 15 s instead of 45. It also keeps the clamping
|
||
policy in one place instead of three clients, and matters more while paused,
|
||
where no position updates arrive at all. So the wire carries
|
||
`sint32 delta_millis` and the step (15 s) is a client constant.
|
||
|
||
*It also uncovered a live panic.* `seek_to` did
|
||
`time.clamp(Duration::from_secs(1), duration)`. `Ord::clamp` asserts
|
||
`min <= max`, and `duration()` returns **0** whenever the source reported no
|
||
length (HLS, some streams) — so that call panicked the engine thread, killing
|
||
audio, on any duration-less track. Unreachable only because nothing called it;
|
||
wiring seek made it reachable from user input, which the hard rules forbid. It
|
||
is now saturating arithmetic in a pure, exhaustively-tested function.
|
||
|
||
Boundaries: backwards saturates at 0 and never enters the previous track;
|
||
forwards stops 1 s short of the end so the track finishes through the ordinary
|
||
end-of-stream path (which advances the queue) rather than relying on
|
||
seek-to-exact-end, which every decoder treats differently; an unknown duration
|
||
has no upper clamp and the decoder decides. The engine emits `Elapsed` from the
|
||
seek path itself, because `tick()` skips a paused sink and a paused seek would
|
||
otherwise show the old position until playback resumed. An unseekable source
|
||
(SoundCloud's HLS) warns server-side and changes nothing — clients never move
|
||
the position themselves, so there is nothing to correct.
|
||
|
||
Bindings landed on `Ctrl-b`/`Ctrl-f` at the user's request (a first round used
|
||
`,`/`.`). They join the existing control-chord family (`Ctrl-n`/`Ctrl-p`,
|
||
`Ctrl-d`/`Ctrl-u`); plain `f` still toggles the spectrum because the TUI's
|
||
`lookup` compares every modifier but `SHIFT` exactly. In the browser `Ctrl-f`
|
||
would open the find bar, but the keydown handler already `prevent_default`s any
|
||
chord that resolves — binding it is enough to claim it.
|
||
|
||
Deferred: absolute seek plus click-to-seek on the web progress gauge (a
|
||
compatible proto3 field addition; the gauge is already rendered), a
|
||
configurable step, and chapter-aware seek.
|
||
|
||
Verified: 20 audio-player tests (5 new, covering `i64::MIN`/`MAX`, zero
|
||
duration, sub-second tracks, composition, near-end saturation), 120 cbd-tui,
|
||
21 cbd-web, crabidy-server, fmt clean, the wasm bundle builds. Not exercised:
|
||
an actual seek through an audio device.
|
||
|
||
### Fixed alongside: image enclosures listed as episodes (`/rss`)
|
||
|
||
Reported from a real subscription: an episode failed with "the format of the
|
||
data has not been recognized" on a `cdn.netzpolitik.org/…jpg` URL.
|
||
|
||
`audio_url` preferred an audio-typed enclosure but then fell back to *any*
|
||
media URL — and a WordPress blog feed attaches each post's featured **image**
|
||
as an `<enclosure>`, structurally identical to a podcast enclosure apart from
|
||
`type="image/jpeg"`. `https://netzpolitik.org/feed/` is 25 items, 25 JPEG
|
||
enclosures, and no audio reference of any kind, so every post became an
|
||
episode that could not play.
|
||
|
||
An enclosure is now accepted when its type says audio, when the feed omits the
|
||
type (many hand-rolled feeds do), or when a generic `application/octet-stream`
|
||
is backed by an audio file extension — and rejected otherwise, so images and
|
||
video are skipped. Audio-typed still wins, so a show publishing both plays as
|
||
audio. When a feed has entries but none carry audio, the server says so
|
||
("this looks like a blog feed rather than a podcast feed"): an empty listing
|
||
explains nothing on its own, and no URL goes in the message.
|
||
|
||
Also raised `DEFAULT_MAX_FEED_BYTES` 8 MiB → 32 MiB. Logbuch:Netzpolitik, 559
|
||
episodes in, is a healthy 6.8 MiB — feeds carry their whole back catalogue with
|
||
full show notes, so the first cap would have started refusing real feeds within
|
||
a year or two. Still bounded, still enforced while reading, still lowerable via
|
||
`max_feed_bytes`.
|
||
|
||
Verified: 30 rssdy tests (5 new, over feed-rs's real entry shapes: image-only,
|
||
mixed image+audio, untyped, generic-with-extension, generic-without and video),
|
||
and the two live feeds — netzpolitik.org/feed/ now yields 0 episodes with the
|
||
warning, logbuch-netzpolitik.de/feed/mp3 (→ feeds.metaebene.me/lnp/mp3) yields
|
||
559 `audio/mpeg` episodes with `HH:MM:SS` durations.
|
||
|
||
### Follow-up: the keys settled on `,` `.` `<` `>`, and the gauge is clickable
|
||
|
||
Three rounds on the bindings, and the last one had a reason beyond taste.
|
||
`Ctrl-b`/`Ctrl-f` work fine in a browser — the keydown handler already
|
||
`prevent_default`s any chord it resolves — but `Ctrl-n`, the long-standing
|
||
next-track chord, does **not**: Chrome and Firefox handle it as "new window"
|
||
above the page, where `preventDefault` cannot reach. The web client therefore
|
||
had no working next-track key at all.
|
||
|
||
Plain printable characters dodge the whole reserved-chord question, so seek and
|
||
track-skip now share two keys in both clients: `,`/`.` seek 15 s, `<`/`>` skip a
|
||
track. Same key, shift = bigger jump — self-teaching, and `<`/`>` are the marks
|
||
engraved on those keys (mpv uses them for the same thing). `Ctrl-n`/`Ctrl-p`
|
||
stay bound as the terminal's primary chords; the web help documents the pair
|
||
that always works.
|
||
|
||
**Click-to-seek shipped without absolute seek**, which had been the deferred
|
||
item. The click maps the pointer's x within the gauge to a fraction of the
|
||
duration and sends `target - position`. Relative is right here even though the
|
||
gesture is absolute: the position it subtracts is the one drawn on the bar the
|
||
user just aimed at, at most one 250 ms tick old — far under one pixel of the
|
||
bar. That staleness is only fatal for a *repeated* key, which is why keys still
|
||
send a fixed step and let the server accumulate. The arithmetic is a pure
|
||
function in `state.rs`, tested on the native target: clicks behind the playhead
|
||
seek back, the edges are exactly the track's ends, a fraction outside `[0, 1]`
|
||
is clamped rather than extrapolated, and a duration of 0 declines.
|
||
|
||
Also: enabling the web-sys `DomRect` feature, without which
|
||
`Element::get_bounding_client_rect` does not exist. Only the wasm build catches
|
||
that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native
|
||
clippy never compiles it. Second time this session that the wasm build was the
|
||
only gate that would have caught a web-client mistake.
|
||
|
||
## TUI volume display (2026-07-27)
|
||
|
||
The now-playing pane now reports the output level — `Volume: 85%`, or
|
||
`Volume: 85% (muted)`.
|
||
|
||
The display was the small half of this. `cbd-tui` was **discarding** the volume
|
||
it was already being sent — `StreamUpdate::Volume(_)` was a
|
||
`/* FIXME: implement */`.
|
||
`Init` dropped `volume` *and* `mute` too, so even after wiring the stream the
|
||
pane would have started out wrong and only corrected itself once the user
|
||
touched either control. Both are wired now; the web client had been reading all
|
||
three fields all along.
|
||
|
||
Muting keeps the number on screen rather than replacing it, because the server
|
||
reports the level it would unmute to (`PlayerEngine::volume` returns
|
||
`pre_mute_volume` while muted) — that is the level the user is about to adjust,
|
||
so it is the useful one. This retires the old `, Muted` suffix.
|
||
|
||
The line is drawn whether or not a track is loaded — it was previously inside
|
||
the `if let Some(track)` branch. Shuffle, repeat and volume describe the
|
||
*server*, and an idle player is exactly when you reach for `K` blind.
|
||
|
||
Formatting is a pure function so its edges are unit-tested: the wire carries a
|
||
`float`, so NaN and infinity are reachable from a wrong peer and read as `--`
|
||
rather than `NaN%`, and negative zero reads as `0%`.
|
||
|
||
Noted, not fixed — the web volume slider is `max="1.5"` while the engine clamps
|
||
to `1.1`, so the top third of its travel silently snaps back. A one-character
|
||
fix in `cbd-web`, but it is the web client's bug, not this change's.
|
||
|
||
## TUI volume display — the server was the bug (2026-07-27)
|
||
|
||
The display shipped and read `Volume: 0%` always. The TUI was fine; the level
|
||
never existed to be shown. Three separate holes in `crabidy-server`, each of
|
||
which alone was enough to break it:
|
||
|
||
1. **`Init` hardcoded `volume: 0.0`** (and `mute: false`, and a zeroed
|
||
`TrackPosition`). This is the whole reason it read 0% rather than a stale
|
||
number.
|
||
2. **`PlaybackCommand::ChangeVolume` broadcast nothing.** It read the volume,
|
||
set the new one, and told no one — so no client ever learned the level had
|
||
changed, and the display could never recover from the bad init value. The
|
||
web slider had the same silence, and never tracked `J`/`K` either.
|
||
3. **`PlaybackCommand::VolumeChanged` / `MuteChanged` are dead variants** —
|
||
handled in `playback.rs`, sent by nobody. They look like the volume
|
||
broadcast path while doing nothing, which is presumably how (2) went
|
||
unnoticed. Left in place (removing them is unrelated cleanup) but worth
|
||
knowing about.
|
||
|
||
`ChangeVolume` now broadcasts the level the engine *took*, so clients see the
|
||
clamp at 1.1 rather than what they asked for, plus `Mute(false)`, because
|
||
`set_volume` unmutes and the indicator would otherwise stick on muted.
|
||
|
||
The hardcoding had a cause worth recording: the init response is built while
|
||
holding the queue's `std::sync::Mutex` guard, so the block cannot await the
|
||
player. The player reads now happen *before* the lock is taken.
|
||
|
||
**Bounded, because `Init` is the connect path.** The engine thread is
|
||
single-threaded and can be 30 s deep in opening a network stream, so each read
|
||
gets a 1 s budget and falls back to what the engine starts at. The budget is
|
||
short on purpose: the engine either answers in microseconds (idle) or not for
|
||
tens of seconds (mid-`Play`), so the number only decides how long the playback
|
||
loop stalls before giving up. A late reply lands in a dropped receiver, which
|
||
the engine only logs.
|
||
|
||
Fixing the init **position** also fixes click-to-seek against a *paused*
|
||
server: no position ticks flow while paused, so the web client's position sat
|
||
at the hardcoded 0, and a gauge click sent `target - 0` — which the engine then
|
||
added to the real position, seeking to roughly twice the intended point.
|
||
|
||
Not covered by a test: `Player::new` is only reachable through `Playback::new`,
|
||
and the engine thread opens an audio device, so there is no device-free way to
|
||
construct one here. `is_muted()` (a new engine getter — only `toggle_mute`
|
||
existed, which cannot be used to *ask*) is likewise verified by reading.
|
||
|
||
## Web volume slider bound (2026-07-27)
|
||
|
||
The slider was `max="1.5"` while the engine clamps to `1.1`, so its right third
|
||
was unreachable: the fill stopped at 73% of the track and the rest stayed empty
|
||
no matter how far the thumb was dragged. Fixing the server to broadcast the
|
||
level it actually took (previous entry) is what made this visible — the thumb
|
||
now springs back from that dead stretch instead of sitting where it was
|
||
dropped.
|
||
|
||
`max` is now a named `MAX_VOLUME` constant that has to track
|
||
`PlayerEngine::set_volume`'s clamp. It cannot be imported: `audio-player` is
|
||
native-only and the web client is wasm, so the two ends of this pair are kept
|
||
in agreement by the comment on each.
|
||
|
||
Raising the engine's clamp instead was the alternative — 1.1 is deliberate
|
||
headroom, and more gain risks clipping — so the slider was the side to move.
|
||
|
||
The tooltip also reports the level as a percentage now, which is the web
|
||
counterpart of the TUI's `Volume: 85%`.
|
||
|
||
## Web pane tabs (2026-07-27)
|
||
|
||
Below 700px the panes cannot sit side by side, so the layout already stacked
|
||
them and collapsed the unfocused one to a title strip. Switching, though, was
|
||
only bound to `Tab` and to a tap on that strip — awkward on a phone, where
|
||
there is no `Tab` key.
|
||
|
||
The top bar now carries a `library` / `queue` pair of buttons that set the
|
||
focus. They are shown at every width rather than behind a media query: on
|
||
desktop they double as the readout of which pane the keys go to, which the
|
||
inset border says only faintly.
|
||
|
||
The unfocused pane is not rendered at all below that width. It used to
|
||
collapse to a strip, which was its toolbar and the top of its list with the
|
||
rest clipped — all still taking taps, so aiming at the strip hit the wrong
|
||
pane's list, or the library's "play", which replaces the queue. With the tabs
|
||
carrying the switch, the strip had nothing left to earn.
|
||
|
||
## Web seek buttons (2026-07-27)
|
||
|
||
The 15-second seek buttons were `⏪`/`⏩` (U+23EA/U+23E9), which carry emoji
|
||
presentation: a browser with a color emoji font drew them as full-color
|
||
pictures, taller than the line and off its baseline, in a row that is
|
||
otherwise monochrome type.
|
||
|
||
They are `«`/`»` now — plain text glyphs at the weight of the `‹` in the
|
||
library toolbar, and lighter than the prev/next marks either side, which suits
|
||
a nudge next to a skip.
|
||
|
||
`font-variant-emoji: text` on the controls row asks for the text form of the
|
||
prev/next/pause marks, which have the same emoji presentation. It is ignored
|
||
where unsupported, at no cost: those glyphs are the fallback anyway.
|
||
|
||
## TUI spectrum color (2026-07-27)
|
||
|
||
The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane
|
||
borders, so they read as chrome rather than as the thing that is moving.
|
||
|
||
`spectrum_color` in the client config now picks the color, parsed by ratatui's
|
||
`Color: FromStr` — so a `#rrggbb` triple, a color name, or a 0-255 palette
|
||
index all work, and a name or index defers to the terminal theme. It defaults
|
||
to `COLOR_RED`, the red the queue marks the playing track with, so the two
|
||
agree on what "now" looks like.
|
||
|
||
The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX`
|
||
for the config file, since one is a `Color` and the other a string a user
|
||
edits. `the_default_spectrum_color_is_the_queue_red` is what stops them
|
||
drifting.
|
||
|
||
A config file is user input, so `config::spectrum_color` reports an unparsable
|
||
value on stderr and falls back to the red rather than failing. It resolves in
|
||
`run`, before the alternate screen is entered, or the warning would be drawn
|
||
over and lost. Empty counts as unset for the same reason a missing key does.
|
||
|
||
No CLI flag: the other options have one because they are things you override
|
||
per invocation, and a color is not.
|
||
|
||
Existing config files predate the key. They keep working — every field of the
|
||
`ClapSerde` opt struct is optional — and take the default, but the new key
|
||
only appears in files written from now on.
|
||
|
||
## TUI spectrum shading and peak hold (2026-07-27)
|
||
|
||
Modelled on BeSpec's LED mode: bars shaded by height, with a peak marker riding
|
||
above each one.
|
||
|
||
Gradients are possible in a TUI because every cell carries its own foreground.
|
||
The useful accident is that shading by *height* makes the color a function of
|
||
the row alone, so it costs nothing per column — the paint is decided once per
|
||
row and the row's cells still coalesce into a couple of spans.
|
||
|
||
`gradient_color` interpolates the configured base from 55% brightness at the
|
||
floor to 30% toward white at the top. Only a `Color::Rgb` base can be
|
||
interpolated: a color name or a palette index is a reference into the
|
||
terminal's own theme, whose RGB value is not the client's to know, so those
|
||
render flat whatever `spectrum_gradient` says. Documented rather than
|
||
worked around, since guessing an RGB for `red` would defeat the point of
|
||
naming it.
|
||
|
||
The peak hold is per-bin state in `NowPlaying`, advanced in `update_spectrum`:
|
||
a bin at or above its peak raises it instantly, otherwise the peak falls
|
||
`PEAK_DECAY` (0.03) a frame — about 1.7s from full scale at the server's 20 fps.
|
||
The server keeps streaming zeroed frames while audio is idle, so markers fall
|
||
away on pause instead of freezing on screen. Bin count comes off the wire, so
|
||
the peaks `resize` to whatever arrives.
|
||
|
||
A marker is drawn only in a row the bar does not reach at all. A cell holds one
|
||
glyph, so a marker inside the bar's own top cell would replace up to seven
|
||
eighths of bar to repeat what the bar's top edge already shows. Skipping it
|
||
loses nothing: a peak that close to the bar is not news.
|
||
|
||
The three spectrum options (`spectrum_color`, `spectrum_gradient`,
|
||
`spectrum_peak_color`) now resolve together into a `SpectrumStyle`, which
|
||
replaces `set_spectrum_color`. `spectrum_peak_color = "none"` (or `"off"`)
|
||
draws no markers — also the escape hatch for a terminal font without U+2594.
|
||
Neither word is a color, so neither shadows a value someone might have meant.
|
||
|
||
`level_of` now guards every bin: they are wire floats, so NaN and infinity read
|
||
as silence in one place rather than being left to the arithmetic. Neither could
|
||
panic before — `clamp` propagates NaN and a NaN float cast saturates to 0 — and
|
||
a NaN can never become a held peak, since `level >= *peak` is false for it. The
|
||
guard is so the peak comparison and the glyph arithmetic agree on what a
|
||
nonsense level means instead of each shrugging it off differently.
|
||
|
||
## TUI spectrum: segments, seams and shadows (2026-07-27)
|
||
|
||
A second pass over the shading and peaks, after seeing them run.
|
||
|
||
**Purple, not white.** The gradient now interpolates from the dimmed base at
|
||
the floor to a configured `spectrum_top_color` — `COLOR_SECONDARY`, the
|
||
secondary purple — at the top, instead of blending toward white by a
|
||
fraction. Naming the far end is both what was wanted and simpler to explain
|
||
than a blend factor: the top row *is* the top color. `"none"` ramps
|
||
brightness alone. A named top color cannot be interpolated any more than a
|
||
named base can, so it falls back to the brightness ramp rather than the
|
||
client inventing an RGB for `light-blue`.
|
||
|
||
**Filled shadows.** A peak now fills the gap between the bar and its held
|
||
maximum rather than marking it with a rule, so each bar trails a shadow of
|
||
where it just was. The rule is kept as `spectrum_peak_fill = false`, since it
|
||
is also the fallback for a font without U+2594. The old "never inside the
|
||
bar's own rows" constraint carries over unchanged, and expresses itself
|
||
naturally now that the shadow is drawn with the same `cell_eighths` the bar
|
||
uses.
|
||
|
||
**Seams, not gaps.** The first attempt at "narrower bars, clearer dividers"
|
||
made the bars *separate* — one-cell sticks with blank columns between them —
|
||
which was wrong. What was wanted is one connected field with the segments
|
||
legible. So:
|
||
|
||
- The divider between bars is now the bar itself at 35% brightness rather
|
||
than an empty column. Neighbours are distinguishable, the field stays
|
||
continuous, and — because the seam is a cell of the bar, not a hole — the
|
||
full 24 bins still fit a normal pane at a pitch of two.
|
||
- `spectrum_bar_width` became a *minimum*: bars take the spare columns of
|
||
their slot, seams stay exactly `spectrum_bar_gap` wide. Otherwise a pane
|
||
that divides unevenly gives bars of different widths, and an uneven bar
|
||
reads as an uneven level — a lie about the data, where an uneven seam is
|
||
only untidy.
|
||
- The horizontal divider is `spectrum_row_gap` eighths left unlit at the top
|
||
of every cell, so a full row draws `▇` and each value row reads as its own
|
||
segment. One eighth costs 1/8 of a row's resolution and is the subtlest
|
||
line a cell can hold; a whole row of gap would cost half the resolution,
|
||
which is why the clamp stops at 4.
|
||
|
||
A cell is the smallest thing a terminal can color, so the two dividers cannot
|
||
be made literally identical: the vertical one is a dimmed column, the
|
||
horizontal one a fraction of a row. Both are thin and neither is black.
|
||
|
||
**Time, not frames.** `spectrum_peak_fall` is seconds for a full-scale shadow
|
||
to reach the floor, and `update_spectrum` measures the interval between frames
|
||
rather than assuming the server's ~20 fps. The frame rate is the server's
|
||
business and may change; a per-frame decay silently retunes itself when it
|
||
does. `advance_spectrum` takes the interval as an argument so the fall is
|
||
testable without a clock, and the renderer floors the divisor at
|
||
`MIN_PEAK_FALL` rather than trusting the config layer's clamp — a zero there
|
||
would divide by zero and freeze every shadow on screen.
|
||
|
||
Seven appearance keys is a lot of config surface, justified by these being
|
||
pure appearance with no right answer: each one is a question that came back
|
||
with a different answer than the default assumed.
|