# 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.