# 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 `/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/` (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 `/crabidy/captures//` 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 `/crabidy/bookmarks//`, 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 `/crabidy/queues/`, so saved queues are ordinary browsable, queueable library folders. Every queue is a folder of order-prefixed (`0001 .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.