crabidy/plan/summary.md

999 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Implementation summaries
## capture visibility + log redaction (2026-07-21, follow-up)
"Problems with capturing" turned out to be a display bug: the capture
had succeeded on disk, but the TUI's `RpcClient` caches every library
listing for the whole session, so a `/captures` (or `/queues`,
`/bookmarks`, `/fs`) listing visited once never showed later captures
or saved queues until a restart. Listings under those mutable roots
are now always refetched — they are cheap local directory walks on the
server — while remote provider nodes (tidal, youtube) keep the cache
that makes back-navigation instant (`is_cacheable`, unit-tested).
Found alongside in the same log: the player engine's `play` span
recorded the full stream URL — googlevideo `sig` tokens included — into
`cbd.log`. Sources are now logged as `scheme://host/…` only (local
paths verbatim; `display_source`, unit-tested). 181 workspace tests
green.
## youtube stream fetching (2026-07-21, follow-up)
The rustypipe swap fixed the decode problem but real playback then hit
YouTube's tokenless-fetch enforcement, measured live: every stream URL
serves exactly its leading 1 MiB (403 beyond — plain, open-ended, and
oversized requests are rejected outright, and fresh URLs refuse offset
starts, killing URL-per-window chaining). PO tokens would lift the cap,
but rustypipe only attaches them to web clients whose signature
deciphering is currently broken upstream (verified on git master;
`rustypipe-botguard` built and tested — ineffective through the iOS
client). `yt-dlp` still solves the ciphers; its URLs stream the whole
file at a throttled ~32 KB/s — double the audio bitrate.
Shipped: (1) **windowed HTTP fetching** everywhere — a
`WindowedHttpStream` `SourceStream` in audio-player (bounded ~1 MiB
ranges, 200-body fallback for range-ignoring servers, eager
seek/reconnect so rejected windows fail typed instead of retrying
forever, URLs never in errors) and the same windowing in the capture
downloader (strict-CDN test stitches windows byte-exact); (2) `yt-dlp`
back as a **stream-URL-only sidecar** — all metadata stays on
rustypipe; a missing binary degrades to 1 MiB streams with a warning,
a failing call falls back to the rustypipe URL; (3) `botguard_bin`
config passthrough so streams flip back to pure Rust when upstream
deciphering recovers; (4) capture per-track deadline raised to 30 min
for the throttle. Live-verified end to end on the exact track from the
user's log: sidecar URL in 3 s, windowed stream + rodio decode
producing samples at 4.3 s. 179 workspace tests green (12 new);
`quality/youtube-rustypipe.md` gained a checked "Stream fetching"
section.
## youtube-rustypipe (2026-07-21)
Built per `plan/youtube-rustypipe.md`: the ytdy provider's `yt-dlp`
subprocess engine was replaced with the pure-Rust **rustypipe**
Innertube client, fixing broken playback along the way.
Root cause of "search works but nothing plays": `-f bestaudio` selects
WebM/**Opus**, and the player (rodio + symphonia) has no Opus decoder.
The new engine picks the highest-bitrate `audio/mp4` (AAC) stream,
which symphonia decodes — verified live end to end (rustypipe stream
URL → download → `rodio::Decoder` produces samples). Download captures
of YouTube tracks now get playable `.m4a` files too.
The alternatives the user suggested were live-tested first:
`rusty_ytdl` 0.7.4 searches fine but returns empty stream URLs (cipher
rotation outran it), `rustube` is unmaintained since ~2022,
`rust-yt-downloader` is a thin CLI. `rustypipe` 0.11.4 worked for
everything (see `architecture/youtube-rustypipe.md`).
Design: an `Extract` trait seam (search, video, audio stream URL,
saved playlists, playlist videos) with `RustyPipeExtractor` as the
real implementation — provider logic is tested against a programmable
fake (no network, no fake shell scripts). Login keeps the `cookies`
setting (Netscape export) via `user_auth_set_cookie_txt`, cache-first:
rustypipe refreshes and persists the rotated cookie under
`<config>/crabidy/rustypipe/`, so it outlives the stale export; any
login failure degrades to logged-out. Saved playlists replace the
never-validated `feed/playlists` scrape; playlist nodes page up to
1000 tracks. `ytdy.toml` loses `binary` (old keys tolerated),
`yt-dlp` left `devenv.nix`, ytdy no longer needs `tokio/process`.
Deviations: none from the new architecture doc; the original
`youtube-provider.md` engine decision (D1) is marked superseded.
Live probe (search → mp4 stream URL → 206 fetch → metadata) ran
against real YouTube and was removed after passing. 169 workspace
tests green (ytdy: 10 + 2 extractor tests, all offline); every gate in
`quality/youtube-rustypipe.md` checked.
## incremental-captures (2026-07-21)
Built per `plan/incremental-captures.md`: download captures are now
**incremental and resumable**, uncapturable tracks are first-class
**skipped** entries, and captures stream **progress** to clients.
- **Skipped playable (fsdy + proto).** `[playable] skipped = true` is a
fourth, mutually exclusive playable; `Playable::Skipped`,
`from_track_skipped`, and a new wire flag `Track.is_skipped` (set by
`to_track`). `from_track` preserves skipped-ness, so persisted queues
and bookmarks keep the marking instead of degrading it into a dead
link. `get_urls_for_track` on a skipped file is a typed `FetchError`.
- **Incremental walk.** The shared capture walk now runs in two phases:
enumerate (dirs + tracks, caps enforced — the total is known before
the first download) then fetch. `Sink::Download` writes straight into
`captures/<name>` (no tmp/swap): satisfied entries — parseable toml,
non-skipped playable, audio present — are reused; skipped, broken, or
audio-less entries are re-captured; uncapturable sources (skipped
source track, unresolvable stream, non-http playable) are recorded as
skipped tomls instead of silently omitted; a real download failure
aborts the run but keeps everything written, so re-capturing the same
name resumes. The byte budget counts only bytes downloaded per run.
Bookmarks keep tmp-and-swap overwrite semantics unchanged.
- **Progress + accept-then-stream RPC.** New `CaptureProgress` update on
the stream (name, download, done/total/skipped, terminal
finished/error). `CaptureLibraryNode` replies once validation (name,
store, download blessing) passes; the walk runs detached and its
bounded progress channel is forwarded into the update broadcast. This
also unfreezes the TUI: its poll loop used to await the whole capture.
- **Playback.** `play` skips `is_skipped` tracks without a provider round
trip and bounds the whole skip loop to one full queue pass — an
all-skipped queue with repeat on now stops instead of hammering the
provider forever (pre-existing spin fixed).
- **TUI.** Skipped tracks render red in queue and library (playing-track
marker keeps precedence). A `CaptureBoard` renders progress lines at
the bottom of the library pane (`capturing faves 3/12 (1 skipped)`),
lingering 5 s on success and 10 s (red) on failure. The `W` help entry
and the capture input label warn that captures are slow and resumable.
Contrast fix: colored items (editable/marked/skipped/current) switch to
the dark foreground under the focused selection bar.
Deviations from the architecture doc: `tracks_done` counts skipped
entries too (the ratio must reach the total on success) — doc and proto
reconciled; the input-overlay warning was shortened to
"capture (slow, resumable)" to fit narrow panes. `taplo` reports a
pre-existing formatting issue in `.opencode/skills/skill-authoring/`
(not touched here). 167 workspace tests green (13 new);
every gate in `quality/incremental-captures.md` checked.
## cbd-bundle (2026-07-21)
Built per `plan/cbd-bundle.md`: a new **`cbd`** binary bundles server
and TUI. Both former binaries became libraries with thin mains —
`crabidy_server::serve(addr)` is the extracted server startup
(orchestrator, queue store, playback, player forwarder, tonic), and
`cbd_tui::run(config)` the extracted client loops; the standalone
binaries behave exactly as before. `cbd` sets up one file-based tracing
subscriber for both halves (the terminal belongs to the TUI), spawns
`serve` on the fixed listen address, polls a TCP connect against the
TUI's configured server address until ready (bounded, generous — first
runs may sit in a provider login), then runs the TUI. An
already-running standalone server just gets adopted (the in-process
bind fails on the occupied port and is deliberately ignored once the
socket is reachable); a server that dies before readiness surfaces its
real error. Quitting the TUI ends the process and the in-process
server — the continuously persisted current queue makes that safe.
Deviations: none of substance — the refactor moved code verbatim
(`crabidy_server::` → `crate::` path rewrites aside). The live probe
booted the extracted stack on a free port through the same readiness
poll `cbd` uses: real tidal login, all providers, playback, queue
restore, TCP accept in ~1 s (probe removed after passing). 154
workspace tests green (2 new in `cbd`); every gate in
`quality/cbd-bundle.md` checked.
## captures follow-up: W on queues and bookmarks (2026-07-21)
Small fix on top of the captures feature: `/queues` and `/bookmarks`
nodes are now `W`-capturable. `fsdy::Client` gained
`with_downloadable_nodes()` (instance-wide `is_downloadable`, applied to
the queues and bookmarks mounts; `/fs` and `/captures` stay off), and
the download sink softens all-or-nothing for exactly one case: a track
whose source cannot be captured — stream resolution fails, or resolves
to a non-http(s) target like a local file playable — is skipped with a
warning instead of aborting, since queue/bookmark captures mix
providers. Real download failures (bad status, transport, timeout) stay
fatal. `architecture/captures.md` D3/D4 reconciled; new tests
`downloadable_instances_flag_every_node` (fsdy) and
`download_capture_skips_uncapturable_tracks` (capture store).
## youtube-provider (2026-07-21)
Built per `plan/youtube-provider.md`: a new workspace crate **`ytdy`**
mounts YouTube at `/youtube`, backed by a `yt-dlp` subprocess (declared
in `devenv.nix`). All extraction goes through one `Engine` seam:
argv-only invocations with `--no-warnings`, an optional `--cookies`
flag, a per-call timeout (`kill_on_drop`), a 32 MiB stdout cap, and
typed `EngineError`s — tests drive the whole provider through a fake
shell-script binary, no network.
Search needs no login and mirrors tidal's search exactly: `%` on
`/youtube/search` creates an in-memory term (deduplicated, implicitly
recreated on stale paths, rename re-searches, delete idempotent), whose
node lists the top N (`ytsearchN:`, default 20) results as queueable,
downloadable tracks. With a readable cookies file configured in
`ytdy.toml` ("logged in"), a `playlists` subtree appears
(`feed/playlists` flat listing → playlist nodes with tracks); an
unreadable cookies file degrades to logged-out with a warning, never a
failed init. Streams resolve via `-f bestaudio/best -g`; captures work
end to end (`extension_for` gained `audio/webm → webm`). The
orchestrator wires `/youtube` non-fatally: a failed `--version` probe
disables the provider, nothing else.
Deviations: `Entry` keeps separate `uploader`/`channel` fields with an
`artist()` preference — the planned serde alias rejects real yt-dlp
output as a duplicate field (found by the live probe). The live probe
validated search, stream resolution, and a real download capture
(252 KB webm) through the capture store; the **playlists feed
invocation is live-unvalidated** (no cookies on this machine) — flagged
in `architecture/youtube-provider.md` as the standing risk. 150
workspace tests green (8 new in `ytdy`); every gate in
`quality/youtube-provider.md` checked.
## captures (2026-07-21)
Built per `plan/captures.md`: `W` (shift) on a downloadable library
selection captures the subtree like a bookmark, but into
`<config>/crabidy/captures/<name>/` with every track's audio
**downloaded** next to its order-prefixed toml — the toml's playable is
the audio file's *relative* name (`TrackFile::from_track_with_file`), so
a capture plays with no provider round trip and the folder stays
relocatable. `/captures` is a fourth `fsdy` instance (editable top
level, nothing reserved): browse, queue, rename (`e`), delete (`d`),
and re-capture to refresh; the audio files are invisible to listings
(only dirs and `*.cbd-track.toml` count).
The bookmark walk was extracted into `capture.rs`
(`capture_into`/`write_tree`, `Caps`, one `CaptureError` for both
stores) parameterized by a per-track `Sink``Link` is byte-identical
bookmark behavior, `Download` fetches the first `get_urls_for_track`
URL through one shared reqwest client (30 s connect timeout, 600 s
per-track deadline, no retries), streams the body to disk against a
capture-wide byte budget, picks the extension from `Content-Type` (URL
path, then `bin`, as fallbacks), and writes the toml only after the
audio succeeded. Download caps: 1 000 dirs, 500 tracks, 4 GiB. Still
all-or-nothing with temp cleanup; downloads are sequential inside the
one spawned capture task. Download error messages carry the track's
library path, never the stream URL (`reqwest::Error::without_url`).
Nodes opt in via new additive proto flags
(`LibraryNode.is_downloadable = 8`, `LibraryNodeChild = 7`). Tidal sets
them centrally at the end of `get_lib_node`: downloadable = queueable
**or lists tracks** (so search-term track results are downloadable even
though the term node isn't queueable); children mirror `is_queable`;
tracks inherit their node's flag in the TUI. The server re-enforces at
the capture root (`Unsupported` → `failed_precondition`); the rpc
gained `CaptureLibraryNodeRequest.download = 3` (additive; old clients
keep bookmarking).
Deviations from the plan/architecture: the naming helper ended up
`audio_file_name` (the path variant was clippy-dead); the tidal flag
rule grew the "or lists tracks" clause (architecture D4 reconciled);
the live probe downloaded a single real track (8.6 MB m4a,
Content-Type-derived extension, replayed through a `/captures`
instance) instead of a whole album — the multi-track walk is
unit-covered and a full album download is needlessly heavy for a smoke
test. 142 workspace tests green (1 new in `fsdy`, 8 in
`capture`/`capture_store`, 3 TUI + 1 extended); every gate in
`quality/captures.md` checked.
## bookmarks (2026-07-21)
Built per `plan/bookmarks.md`: `w` on a queueable library selection now
captures the whole subtree as a **bookmark** — a structure-preserving
snapshot under `<config>/crabidy/bookmarks/<name>/`, mounted read-only at
`/bookmarks` by a third `fsdy` instance. The capture runs on the
orchestrator (a spawned task walking `get_lib_node` iteratively across
any provider): every child node becomes an order-prefixed folder
(`fsdy::dir_name`, sharing the queue entries' sanitizer), every track an
order-prefixed link file, so the case-insensitive listing reproduces the
source order and replaying is plain fs-provider behavior. Caps (1 000
dirs / 20 000 tracks) abort cleanly with the temp folder removed; writes
are tmp-and-swap; re-capturing a name overwrites it. The wire gained one
additive rpc, `CaptureLibraryNode(path, name)` (invalid name/source →
`invalid_argument`, over-cap/disabled → `failed_precondition`). The TUI
opens the existing input overlay prefilled with the selection's title
(`bookmark`), gated on a queueable bare selection.
On top, `fsdy::Client` gained `with_editable_top_level(reserved)`:
editable instances mark their root's child folders
`is_editable`/`is_deletable` and implement rename (no-merge, validated
titles, returns the renamed node) and delete (idempotent, returns the
refreshed root). Applied to `/bookmarks` (nothing reserved) **and
`/queues`** (reserved: `current`) — saved queues are now renamable and
deletable through the existing `e`/`d` flows with zero TUI changes.
`/fs` stays immutable. All 130 workspace tests green (6 new in `fsdy`, 7
in `bookmark_store`, 2 TUI); every gate in `quality/bookmarks.md`
checked. A temporary live probe (removed after passing) captured a
19-track album from the live Tidal API, browsed it with editable flags,
renamed it, resolved it in order, and fetched a stream URL for a
captured link.
The whole feature ran autonomously per standing instruction; decisions
are recorded in `architecture/bookmarks.md` (options + rationale).
### Deviations from plan / architecture (bookmarks)
- **Capture is all-or-nothing**: any provider or write failure mid-walk
aborts the whole capture (temp folder removed) instead of skipping the
failing subtree with a warning — a bookmark that *looks* complete must
*be* complete. The architecture only specified the unreadable-*root*
case; this extends it to every node.
- **Rename to the current name is a no-op success** (returns the node),
not a collision error — the target "exists" only because it is the
source.
- **Rename targets don't pass `disk_path`**: the new folder name is
validated by `validate_folder_name` (no separators, NUL, or leading
dots), which makes it a plain sibling name by construction; the
traversal gate still covers every client-supplied *path*.
- **`track_file_name` was refactored onto a shared `ordered_name`**
helper rather than duplicated for `dir_name` (planned as "shared
sanitizer", realized as one function).
- **Environment note**: builds/tests again ran with a session-local
`CARGO_TARGET_DIR`; no repo change.
## queue-persistence (2026-07-21)
Built per `plan/queue-persistence.md`: queues now survive server restarts,
realized entirely on top of the fs provider. `fsdy::Client` became
instance-mountable (`Client::new(provider_root, disk_root)`); the
orchestrator mounts a second, read-only instance at `/queues` over
`<config>/crabidy/queues/`, so saved queues are ordinary browsable,
queueable library folders. Every queue is a folder of order-prefixed
(`0001 <title>.cbd-track.toml`) **link** files — metadata copied from the
queue entry, `playable.link = Track.path` — written only by the new
`crabidy_server::queue_store::QueueStore` (tmp-and-swap, hidden
`.queue-state.toml` sidecar for position/repeat/shuffle). The playback
loop feeds every queue-state change into a latest-wins `watch` channel; a
persister task debounces, skips unchanged snapshots, and rewrites
`queues/current/`. On startup the server restores tracks, position, and
modifiers from `current/` without ever starting playback. `w` on the TUI
queue pane opens the existing input overlay (`save queue`) and drives the
previously stubbed `SaveQueue` rpc (invalid name → `invalid_argument`,
empty queue/disabled persistence → `failed_precondition`). Reloading a
saved queue is just queueing `/queues/<name>` — the listing rewrites each
link back to its target, so zero new resolve mechanisms. All 116
workspace tests green (10 new in `fsdy`, 9 in `queue_store`, 5 playback,
3 TUI); every gate in `quality/queue-persistence.md` checked. A temporary
live probe (removed after passing) round-tripped a mixed queue — a track
fetched from the live Tidal API plus an fs url track — through persist,
reload, `/queues` listing, and the resolve walk, and the reloaded Tidal
path still yielded a stream URL.
The whole feature ran autonomously per standing instruction; decisions
are recorded in `architecture/queue-persistence.md` (options + rationale).
### Deviations from plan / architecture (queue-persistence)
- **The "no links into `/fs`" rule was dropped** (fs-provider D3): queue
entries persist as links to whatever path the queue held, including
`/fs/...` tracks. Replaced by the one-hop argument —
`get_urls_for_track` never follows a link, so chains die at play time
and cycles cannot recurse. `architecture/fs-provider.md` reconciled.
- **`SaveQueueError` gained `Disabled` and `State` variants** beyond the
stub: `Disabled` (no usable queues directory) maps to
`failed_precondition` instead of masquerading as I/O; `State` covers
sidecar serialization.
- **The orchestrator mounts `/queues` independently of `QueueStore`**:
both derive the directory from `queue_store::queues_dir()`, so a
mount over a not-yet-created folder simply lists as missing until the
store (created in `main`) writes it. No plumbing between the two.
- **Shuffle order is not persisted** (documented in D3/D4 but worth
repeating): restoring `shuffle = true` reshuffles around the restored
current track.
- **The live probe needed no bespoke server run**: provider-layer clients
plus `QueueStore` cover the full D2/D5 story; the gRPC and TUI layers
above are unit-tested.
- **Environment note**: builds/tests again ran with a session-local
`CARGO_TARGET_DIR`; no repo change.
## fs-provider (2026-07-21)
Built per `plan/fs-provider.md`: a second media provider (crate `fsdy`,
`/fs`) that walks one configured root directory and treats
`*.cbd-track.toml` files as serialized track nodes — metadata plus exactly
one playable reference: a local audio file (absolute or relative to the
track file), an http(s) URL, or a crabidy-internal link. The wire types
are unchanged (architecture D1): the only new datastructure is the
on-disk TOML schema. Link tracks rewrite `Track.path` to the target at
listing time (D2), so playback routes to the owning provider through the
orchestrator's existing prefix routing with zero new mechanisms; links
into `/fs` are rejected at parse time, making chains impossible.
Directories list sorted and queue via the default chunked resolve walk;
client paths are decoded and validated in a single helper so they cannot
escape the root; symlinks, hidden entries, and broken files are skipped
with warnings. `ProviderOrchestrator` gained an optional fs client
(non-fatal init from `fsdy.toml`, default root `dirs::audio_dir()`) and
`/fs` routing arms in every trait method. No player or TUI changes were
needed. All 91 workspace tests green (15 new in `fsdy`); every gate in
`quality/fs-provider.md` checked. A temporary live probe (removed after
passing) built a real tree whose link track pointed at a track fetched
from the live Tidal API: listing order held, the link path was
rewritten, and the target resolved a stream URL — the full D2 story
end-to-end.
The whole feature ran autonomously per standing instruction; decisions
are recorded in `architecture/fs-provider.md` (options + rationale).
### Deviations from plan / architecture (fs-provider)
- **Extension renamed to `.cbd-track.toml`** (user request, follow-up
commit): the original `.track.toml` was too generic; the `cbd-` prefix
makes the files unmistakably crabidy's.
- **`TrackFileError::UrlScheme` carries only the scheme**, not the URL:
the parse error ends up in skip-warnings, and a private stream URL may
embed a token (quality gate "no file contents in logs"). The
architecture's schema and behavior are otherwise as designed.
- **The live probe ran at the provider layer**, not against a running
server (no interactive terminal/audio device here, same as previous
features): `fsdy` and `tidaldy` clients driven directly, mimicking the
orchestrator's routing exactly. It also had to *fetch* its link target
first — the well-known id from the progressive-queueing probe is an
album path, and a link must point at a track.
- **`get_lib_node` on a track path is `MalformedPath`** — implicit in
the design, made explicit so the default resolve walk can never
mistake a track file for a directory.
- **Environment note**: builds/tests again ran with a session-local
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo
change.
## progressive-queueing (2026-07-21)
Built per `plan/progressive-queueing.md`: queueing a large nested collection
now fills the queue progressively instead of freezing until the full
resolve. `ProviderClient` gained `resolve_tracks_into` (chunk-streaming over
a bounded channel; sender-drop = done, receiver-drop = cancel) with a
default pre-order walk; tidaldy overrides it so playlists and albums emit
one chunk per fetched 50-track page. The playback loop registers a pending
op per queue command, spawns a forwarder, applies chunks on the loop
(single-writer preserved), broadcasts after every chunk, and starts playback
with the first chunk that makes a track current. `Replace`/`Clear` cancel
in-flight resolves down to the HTTP fetch. The wire gained
`Queue.resolving = 4` (additive); the TUI renders an animated one-to-three
dots pseudo-item after the last queue row while it is set. All 76 workspace
tests green; every gate in `quality/progressive-queueing.md` checked.
Verified against the live Tidal API with a temporary ignored probe (removed
after passing): a 71-album artist streamed its first 19-track chunk (first
album, listing order) while the walk was still running, and dropping the
receiver mid-stream ended the resolve cleanly in 1.8 s instead of draining
the discography.
The whole feature ran autonomously per standing instruction; decisions are
recorded in `architecture/progressive-queueing.md` (options + rationale).
### Deviations from plan / architecture (progressive-queueing)
- **`make_paginated_request_into` became `stream_track_pages_into`**: the
planned generic `AsyncFnMut` page sink dies on a rustc
"implementation of `Send` is not general enough" limitation inside
`async_trait` methods. The concrete method (fixed `Track` item type,
proto mapping and channel send inlined) sidesteps it with the same
page-loop and cancellation semantics.
- **Zero-track warning lives in the forwarder, not `finish_resolve`**: the
forwarder sees each path and its chunk count, so the existing per-path
"resolved to no playable tracks" message survives verbatim; the planned
op-level warning would have had to smuggle paths into `PendingResolve`.
- **Fixed alongside (user-reported)**: Enter on a non-queueable library
item used to blank the queue while audio kept playing. Two causes, both
fixed: `Library::get_selected` now gates the bare selection on
`is_queable` (marks were already gated), and a replace that resolves to
zero tracks no longer touches the queue at all — structurally, since the
queue is only mutated by arriving chunks. Regression test
`queue_ops_ignore_non_queueable_selections`.
- **`Queue` (play-next) captures the current position when the command
arrives**, not per chunk: chunks of one op stay contiguous after the
track the user was on when they pressed the key, even if playback
advances mid-resolve.
- **Live probe scope**: the first full-discography probe was cut short
(hundreds of album fetches for no extra signal) and replaced by a
receive-two-chunks-then-cancel probe — which also exercises mid-stream
cancellation against the live API, which the drain-everything version
could not.
- **Environment note**: builds/tests again ran with a session-local
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo change.
## node-editing (2026-07-20)
Built per `plan/node-editing.md`: search-term nodes (created via `%`) are now
modifiable — `e` opens the input overlay prefilled with the current title and
renames (re-running the search; merge on title collision), `d` deletes
without confirmation (documented decision, architecture/node-editing.md D4).
Capabilities travel as `LibraryNodeChild.is_editable`/`is_deletable` (fields
5/6, child-only — no consumer for node-level copies), surfaced as a `[ed]`
marker; two new rpcs `RenameLibraryNode` (returns the renamed node, TUI
navigates into it) and `DeleteLibraryNode` (returns the refreshed parent).
All 59 workspace tests green; every gate in `quality/node-editing.md`
checked. Verified against the live Tidal API with a temporary ignored probe
(removed after passing): create `beatles` → rename to `rolling stones`
(in-place, 20 tracks / 40 children) → a track queued under the old term
still resolved a stream URL → delete emptied the listing.
The whole feature ran autonomously per standing instruction; decisions are
recorded in `architecture/node-editing.md` (options + rationale per topic).
### Deviations from plan / architecture (node-editing)
- **Self-rename bug caught by the gate tests**: the first
`rename_search_term` implementation deleted a term renamed to itself (the
merge branch removed the "old" slot). Fixed with an explicit `old != new`
guard; the architecture text ("merge on collision") now implicitly means
*distinct* titles.
- **`pane_bindings_only_match_their_own_pane` (help-modal suite) updated**:
it asserted plain `d` is unbound in the library — now it is
`LibraryDeleteNode` by design; the test's queue-only example key moved to
`c`.
- **`delete_library_node` also maps `InvalidInput``invalid_argument`**
although no provider raises it for delete today — keeps the error contract
uniform across the three node-mutation rpcs.
- **End-to-end check ran at the provider layer** (as with search): no
interactive terminal/audio device in this environment; the gRPC handler
and TUI layers above it are covered by unit tests and review.
- **Environment note**: builds/tests again ran with a session-local
`CARGO_TARGET_DIR` (owner-built artifacts in `target/`); no repo change.
## search (2026-07-20)
Built per `plan/search.md`: `%` inside `/tidal/search` opens a one-line input;
the term becomes a persistent (per-process) tree node holding Tidal search
results — 20 tracks queueable in place, plus artist/album results as canonical
`/tidal/artists/...` children. Creatable nodes carry an `is_creatable` flag
end-to-end (proto → provider → TUI marker `[%]` + pane hint). All 48 workspace
tests green; every gate in `quality/search.md` checked. Verified against the
live Tidal API: payload shapes match the existing models (probe kept as the
ignored `probe_search_shapes` test), and a full create→list→resolve-URL round
trip succeeded (`beatles` → 20 tracks / 40 children, idempotent, playable
stream URL from a search-track path).
The whole feature ran autonomously on user instruction; decisions were taken
without mid-stage confirmation and recorded in `architecture/search.md`
(options + decision per topic).
### Deviations from plan / architecture (search)
- **`Library::update` now concatenates tracks and children** (tracks first).
The old code showed tracks *instead of* children, which would have hidden
the artist/album results on term nodes — architecture assumed both would
render. Existing nodes are unaffected (they only ever carry one kind).
- **Search categories degrade independently**: a failing category logs and
contributes nothing; only all three failing is a `FetchError`. The plan
did not specify partial-failure behavior.
- **`get_lib_node` no longer requires a user id up front** — the gate moved
into the favorites arms (planned), which also means `create_lib_node`
validation works fully offline (used by the new unit tests).
- **End-to-end check ran at the provider layer** (temporary ignored test,
removed after passing) rather than driving the full TUI + server — no
interactive terminal/audio device in this environment. The gRPC handler and
TUI layers above it are covered by unit tests and review.
- **Environment note**: `target/` contains owner-built artifacts not writable
by this agent's user; builds/tests ran with a session-local
`CARGO_TARGET_DIR`. No repo change involved.
## help-modal (2026-07-20)
Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
`BINDINGS` table + `lookup` + `key_label`), `app/help.rs` (overlay), the
`App::dispatch`/`DispatchResult` seam, and the rewired event loop in
`main.rs`. All 20 tests pass; every gate in `quality/help-modal.md` checked.
### Deviations from plan / architecture (help-modal)
- **Two-column modal layout.** The architecture assumed a single-column list;
the full table is ~50 rows and would not fit even a 100×40 frame. The modal
renders Global in the left column and Library + Queue stacked in the right
column, with the close keys as a footer line (`Close help: ?, Esc, q`)
derived from the `Scope::Help` bindings instead of a fourth listed group.
The open question "scroll vs truncate" stays resolved as truncate — but
after the column split the content fits ~34×94, so truncation only kicks in
on genuinely small terminals.
- **`Scope` derives `Hash`** (not in the stub) so the chord-uniqueness test
can use a `HashSet`.
- **`QueueInsertHere` description reworded** to "Insert library selection
after this track": `crabidy-server`'s `insert_tracks` splices at
`position + 1`. Same check confirmed the planned "Queue selection after
current track" wording for `LibraryQueueNext`.
- **`main.rs`** passes `tx` to `App::new` without the now-unneeded clone; the
`KeyCode`/`KeyModifiers`/`UiFocus`/`StatefulList` imports moved out with the
old match.
## capture-deletion (2026-07-21)
Deletes under `/captures` now work at any depth and remove data from
disk, behind a TUI confirmation (`architecture/capture-deletion.md`;
direct implementation, no separate plan file — the change is four
bounded seams):
- **Proto**: `LibraryNode.tracks_deletable` (field 9) — node-level
"listed tracks may be deleted", mirroring the `is_downloadable`
inheritance so no `Track` literal anywhere had to change.
`DeleteLibraryNode` doc extended to tracks and recursive folders.
- **fsdy**: `with_deletable_tree()` (only the `/captures` instance sets
it): nested folders delete recursively; track deletes remove the toml
plus its `[playable] file` audio **iff** the canonicalized audio path
stays inside the canonicalized instance root (`..`/symlink-proof);
reserved names and the instance root remain undeletable; everything
idempotent. Deletes now return the actual parent listing (was: root —
identical for the previously-only-possible top-level case).
- **crabidy-server**: captures fsdy instance gains the flag; the
delete RPC path was already generic.
- **cbd-tui**: tracks inherit `is_deletable` from `tracks_deletable`;
`d` under `/captures` opens a modal red `delete <title>? [y/N]` line
(only `y`/`Y` sends, any other key cancels) — other deletables stay
unconfirmed by design; `selected_deletable()` now returns
`(path, title)`.
Tests: 4 new fsdy tests (flags, recursive delete, track+audio delete,
outside-root audio kept) and 3 new TUI tests (confirm-then-send,
cancel-on-anything-else, prompt render) plus a guard in the existing
queues delete test that track deletion stays `NotSupported` there.
188 workspace tests green; clippy `-D warnings` and fmt clean.
## roles-auth (2026-07-21)
Built per `plan/roles-auth.md` from `architecture/roles-auth.md`:
basic-auth role authorization (owner / queue-owner / queue-appender)
with PHC password hashes in the new `crabidy-server.toml`.
- `crabidy-server/src/settings.rs``[auth]` loading; missing file =
open mode, malformed file = startup abort (fail-closed).
- `crabidy-server/src/auth.rs` — ordered `Role`, `minimum_role`
default-deny method table (pinned by a 24-method test),
`Authenticator` (argon2 verify, success-only credential cache,
indistinguishable failures), `AuthLayer`/`AuthService` tower layer
answering trailers-only `UNAUTHENTICATED`/`PERMISSION_DENIED` via
`Status::into_http()`, and `hash_password` for the new
`crabidy-server hash-password` subcommand (clap, stdin → PHC).
- `cbd-tui``user`/`password` config options and flags;
`AuthInterceptor` baking the Basic header into every request via
`CrabidyServiceClient::with_interceptor`.
### Deviations from plan / architecture (roles-auth)
- None functionally. The dev-flow stages were compressed into one
autonomous pass (per standing instruction): stubs went straight to
implementation; `quality/roles-auth.md` gates were verified after
the fact and all hold.
- Denied-action UX in the TUI stays a logged no-op, as recorded in the
architecture's open questions.
## web-client (2026-07-21)
A Leptos/WASM browser client with TUI feature parity, served by
crabidy-server itself. Full dev-flow run: `architecture/web-client.md`,
`quality/web-client.md`, `plan/web-client.md`.
New workspace member **cbd-web** (CSR Leptos):
- `state.rs` / `keymap.rs` — the TUI's pane logic and bindings ported
as pure, DOM-free modules with native `#[test]`s (18 tests). Same
semantics: tracks-before-children, cursor memory, marks-win, capture
progress lines, `is_cacheable`, capture-delete confirmation rule.
- `rpc.rs` — gRPC-web (`tonic-web-wasm-client`) over the same
`crabidy-core` generated client and types as the TUI, with the same
basic-auth header interceptor.
- `app.rs` — one signal store fed by the update stream (reconnecting
backoff), one dispatcher mirroring the TUI dispatch, thin components:
library/queue panes, transport bar, name/confirm/login/help dialogs,
global keyboard wiring.
- `style.css` — pure modern CSS, single `--accent` crab orange-red with
`color-mix` derivations, light/dark via `color-scheme`+`light-dark()`
plus a persisted toggle, phone breakpoint.
crabidy-server changes:
- `web-ui` cargo feature (**default on**); `--no-default-features` =
headless gRPC-only.
- `build.rs` stages `cbd-web/dist` into `OUT_DIR` (or a placeholder
page — plain `cargo build` needs no wasm toolchain), embedded via
`include_dir`.
- `web.rs` serves the embedded bundle (GET/HEAD, index fallback).
- `serve()` refactored to `build_router()`: one axum router with the
gRPC service (auth layer → `tonic-web` GrpcWebLayer → service) as a
route and the web bundle as fallback; `axum::serve` replaces
`tonic::transport::Server`.
Cross-cutting:
- crabidy-core builds for `wasm32-unknown-unknown`: workspace `tonic`
set `default-features = false`, this crate takes codegen-only, native
binaries re-enable transport/router/channel; `build.rs` uses
`build_transport(false)`; config loading gated to non-wasm.
- devenv: `trunk`, `wasm-bindgen-cli`, `binaryen`, the wasm target, and
`build-web`/`serve-web` scripts (which clear `RUSTFLAGS` — the mold
linker flag breaks `rust-lld`).
### Deviations from plan / architecture (web-client)
- **No CRDT / local-first sync layer** (the example template's
automerge/loro): this app is a remote control for one live server
state, so "local first" was scoped to CSR + no-CDN assets + in-memory
caching + localStorage prefs + reconnect. Recorded in the
architecture doc up front.
- **`build_router()` extracted** from `serve()` (not in the plan) so
the three-way routing + auth composition is testable without a live
provider backend (`tests/web_server.rs`, incl. a native-gRPC-over-
axum h2c check).
- **Native dead-code allow** on the cbd-web binary target: the pure
modules are used by wasm + tests, not the native stub binary.
### Verification
202→ tests green across the workspace plus 4 new server routing tests
and 18 cbd-web logic tests; native and wasm clippy `-D warnings` clean;
fmt + markdownlint clean. Live smoke test (server with the real
embedded bundle + stubbed Tidal): `/` serves the shell, the 1.77 MB
wasm/js/css assets serve with correct content-types, deep links fall
back to the shell, unauthenticated and wrong-role gRPC-web calls return
UNAUTHENTICATED, and a native tonic client round-trips over axum.
## tui-search (2026-07-21)
`/` in the library or queue pane opens a live substring filter
(`architecture/tui-search.md`). Implemented directly (small feature).
- New `Filter` helper in `cbd-tui/src/app/list.rs`: keeps the pane's
full list, records visible real indices, maps view↔real. Both panes
route selection, marks, rendering, and `StatefulList` size through
it, so all movement keys work on the filtered view unchanged and the
queue's server-facing positions (remove/set-current) map back to real
indices.
- App gains a modal `search: Option<SearchState>` and
`handle_search_key` (type = live filter, Enter keeps, Esc clears),
an `OpenSearch` action bound to `/` in both pane scopes targeting the
focused pane, and event-loop routing ahead of the input overlay.
- Library resets search on node change; queue preserves it across the
frequent stream updates.
Tests: 2 queue tests (real-position mapping on removal, filter survives
updates) + 3 app tests (library filter/Enter/Esc lifecycle, focus
targeting, dive-clears-filter). 67 cbd-tui tests green; clippy clean.
Scope: TUI only, per the request; web-client parity noted as a
follow-up.
## client-configs (2026-07-21)
`cbd` now reads its own `cbd.toml` instead of sharing `cbd-tui.toml`
(`architecture/client-configs.md`, resolving the open risk in
`architecture/cbd-bundle.md`). One-line change in `cbd/src/main.rs`
(`init_config("cbd.toml")`); same `ServerConfig` type and localhost
default, so `cbd` (local, self-contained) and a remote-pointed
`cbd-tui` coexist on one machine without their `address` settings
colliding. README config table + client-config section updated.
## spectrum (2026-07-21)
A frequency-spectrum bar row under the track progress
(`architecture/spectrum.md`). Because the audio plays on the server and
clients may be remote, the spectrum is produced server-side and
streamed — a local loopback capture (BeSpec's model) could not serve a
remote `cbd-tui`.
- **audio-player**: `SpectrumTap` (a fixed lock-free ring of 2048 `f32`
slots, atomic write index doubling as an idle counter) and
`TappingSource`, which wraps the decoded rodio source and mirrors
each played frame (downmixed to mono) into the tap on the audio
thread — one store per sample, no locks/alloc/logging; `try_seek`
delegated so seeking still works. Exposed via `Player::spectrum_tap`.
- **crabidy-server**: `spectrum::SpectrumAnalyzer` (Hann window +
realfft, log-spaced bins, dBFS→[0,1]) and a ~20 fps task that skips
when no stream subscribers, snapshots the tap, and broadcasts a new
`StreamUpdate::Spectrum(SpectrumFrame{bins})`; it diffs the tap's
frame counter to emit a single zero frame on going idle (bars fall,
don't freeze) without touching the player command path.
- **proto**: `SpectrumFrame` + oneof field 9 on `GetUpdateStream`.
- **cbd-tui**: a bar row (block glyphs `▁..█`, accent color) under the
progress gauge in the now-playing pane; `spectrum` config option
(default true) on both `cbd-tui.toml` and `cbd.toml`.
- **cbd-web**: the same bins rendered as CSS-height accent bars
(parity).
Tests: 2 tap tests (downmix + snapshot ordering), 4 DSP tests (silence,
tone concentrates in one region + stays normalized, wrong-length is
zero-not-panic, monotonic bin edges), 3 TUI render tests (glyph
mapping, bars shown when enabled, hidden when disabled). All workspace
tests green; clippy (native + wasm) and fmt clean.
**Not exercised**: the end-to-end audio→FFT→stream path needs a real
audio output device, unavailable in this headless environment. The
components are unit-tested and the wiring compiles and starts; the live
path should be sanity-checked on a machine with audio.
## capture-local-files + queue-W (2026-07-22)
Two capture fixes (the first two of a set; store relocation and a
central dedup store come as a later refactor).
- **Capturing local playables (#4, `capture.rs`)**: a download capture
used to record any non-http(s) playable as *skipped* — so capturing
an fs node or a queue mixing streamed and local tracks produced red,
audioless entries even though the audio was on disk. `fetch_track`
now routes a local-file source to a new `copy_local`, which copies the
file in next to its toml (source extension preserved, counted against
the run's byte budget); a missing/unreadable source still records
skipped. Rewrote the mixed-queue test to assert copy-not-skip and
added a direct local-copy test.
- **`W` on the queue (#3, `cbd-tui`)**: the queue pane bound only `w`
(save); capturing the queue meant save-then-navigate-then-`W`. Added
`QueueDownloadCapture` on Shift-`W` in the queue scope, which
download-captures `/queues/current` (the continuously persisted live
queue) via the usual name dialog. Bound + dispatch + tests.
Docs: architecture/incremental-captures.md D2 updated (local files
copied, not skipped); root README capture section updated (queue `W`,
local-copy behavior).
Deferred to the refactor: moving queues/bookmarks/captures out of
`.config` into `.local/state` (with migration), and a central
content-addressed audio store so captures dedup and link instead of
copy.
## crabidy content store — the /crabidy provider (2026-07-22)
Green-field refactor per `architecture/crabidy-store.md`,
`quality/crabidy-store.md`, `plan/crabidy-store.md`. Collapsed
`/queues` + `/bookmarks` + `/captures` into one `/crabidy` fs provider
whose track tomls link into a content-addressed store that de-duplicates
audio by provider id and by content hash. No data migration.
Built:
- **Wire (`crabidy-core`)**: `Track.provider_item_id` + `Track.is_captured`,
`LibraryNode.is_captured`, `LibraryNodeChild.is_captured`.
- **`fsdy`**: `Playable::Store` + `PlayableSpec.store` (5-way cardinality,
`StoreName` validation), `from_track_store`, `Client::with_store_root`
and store resolution in `get_urls_for_track`; `to_track` marks store
playables captured. `AlbumMeta` gained `Clone`.
- **`crabidy-server/src/crabidy_store.rs`** (new): `CrabidyStore` owns the
state tree (`dirs::state_dir()/crabidy`) and the data store
(`dirs::data_dir()/crabidy`). `StoreIndex` (provider-id → entry, hash →
entry) derived by scanning `*.cbd-store.toml` at open, updated on write.
`save` (Link/Capture) enumerates the source into a temp folder and swaps
it in atomically (conflict → refuse). `capture_track` runs the D4 flow:
already-store-backed → reuse; provider-id hit → reuse + alias; else
fetch (download to temp, or hash the local file) → hash hit → add
identity + discard bytes; else new store entry. Queue persistence
(`QueueSnapshot`/`QueueState`/`persist_current`/`load_current`/
`spawn_persister`) moved here; `save_snapshot` is the queue-`w`
link-save. Full local-source dedup test suite.
- **`capture.rs`**: reduced to the primitives the store uses — `enumerate`,
`Downloader::download_to` (windowed download to a file, returns ext),
`Progress`, `Caps`. Removed `Sink`/`capture_into`/`write_links`/
`fetch_track`/`copy_local`/`existing_is_satisfied` and `audio_file_name`.
`CaptureError` gained `Conflict` and `Store(StoreError)`.
- **Orchestrator**: one `crabidy_client` + `crabidy_store` replacing the
three fields; single `crabidy_owns` routing arm across all methods;
`get_lib_node` calls `annotate_captured` so captured tracks are marked
in any provider's listing; `crabidy_store()` accessor shares the Arc
with playback.
- **`rpc.rs`**: `capture_error_status` helper; `save_queue` now link-saves
the live queue into `/crabidy`.
- **`playback.rs`**: persists/restores via `CrabidyStore`.
- **`cbd-tui`**: `/crabidy/current` path; captured `|` row marker; delete
confirmation removed (deletes go direct, never touch the store).
- **Providers**: tidal sets `provider_item_id` to the Tidal track id,
youtube to the video id.
- Docs: superseded `bookmarks.md`/`captures.md`/`capture-deletion.md`;
README config/dirs table, single `crabidy` provider, `|` marker,
direct deletes.
Deviations from the plan/architecture:
- **`SaveQueue` RPC kept** (architecture D6 said remove it). Reimplemented
server-side as a Link save of the live queue into `/crabidy` via
`CrabidyStore::save_snapshot`; queue-`w` still uses it, queue-`W` uses
`CaptureLibraryNode(download)` on `/crabidy/current`. Rationale: removing
it would ripple through the auth role table, `cbd-web`, and the TUI
client for no behavioural gain — the two paths produce identical
`/crabidy/<name>` folders.
- **fs `provider_item_id` left empty** (D3 said canonical path). fs
de-duplicates by content hash instead; `to_track` has no disk context to
resolve a relative `file` playable to a canonical path, and hashing a
local file is cheap. "Already in the store → no-op" still works via the
store-root path check.
- **Folder captured-marking is shallow**: `annotate_captured` marks tracks
via the index and marks a node captured only when all its tracks are
captured and it has no child nodes (flat saves, e.g. a captured queue).
Store-backed tracks under `/crabidy` are marked directly by `to_track`.
A nested save's top folder is not marked (its leaf folders/tracks are) —
full-recursion marking is future work.
- **api-design stage** was folded into implementation: for a refactor of
live code there was no separate compiling-stub milestone; the proto and
`fsdy` changes landed as real code and `crabidy_store.rs` was stubbed
(stage 2) then implemented (stage 5).
- Store **garbage collection** stays out of scope (D10): deleting a toml
never reclaims store audio, so orphans can accumulate.
## The CLI (architecture/cli.md)
A comprehensive clap-derive CLI across all three binaries, sharing one
command surface via the `cbd-cli` crate.
### What was built
- **`cbd-cli` executor** (`client` feature): `run_remote` dispatches every
`LibraryCmd`/`QueueCmd`/`GlobalCmd` variant to its gRPC call, mirroring
`cbd-tui`'s `RpcClient` request construction (with a direct `Stop` call,
which had no wrapper). Listings and the queue print human-readably;
captured rows are marked `*`. The endpoint has a 5 s connect timeout and
a 30 s per-request deadline, so a CLI never hangs. Transport/`Status`
errors map to a single-line message (no color-eyre chain).
- **Config writers**: `ServerSettings::store` round-trips `[auth]`
(`skip_serializing_if` keeps the flat shape parseable under
`deny_unknown_fields`); `cbd-tui`'s `config` module gained
`load_first_run`, `apply_overrides`, `store`, and `write_auth` (plus
path-based `_at` variants for tests).
- **`crabidy-server`**: `main` now parses `cbd_cli::ServerCli`; a new
`crabidy_server::cli` module holds `guard`, `scan`, `scan_dir`,
`write_role_hash`, and `connection`. `guard` prints the PHC hash to
stdout (pipeable) and the confirmation to stderr. `scan` walks a folder
(bounded, hidden/symlink-skipped) writing `.cbd-track.toml` sidecars;
`--capture`/`--move` use the new `CrabidyStore::ingest_file`.
`hash-password` was removed (`guard <role> --no-config` replaces it).
- **`CrabidyStore::ingest_file`**: the local-source half of
`capture_track`, factored out — hash, de-dup by content hash, copy or
move into the store, write the sidecar, return the store name.
- **Clients**: `cbd-tui` and `cbd` parse their `cbd_cli` CLIs; the
no-subcommand path loads the TOML config (defaults written on first
run) and applies the flag overrides, then runs as before. `auth` writes
the client config; `library`/`queue`/`global` call `run_remote`.
- **Assets**: each binary crate has a `build.rs` that build-depends on
`cbd-cli` (default features only) and writes completions + a man page
into `OUT_DIR`, and into `$CBD_ASSET_DIR` when set. A devenv
`gen-cli-assets` script produces `dist/completions/**` and `dist/man`.
### Deviations
- **ClapSerde kept, not replaced.** Architecture D2 floated replacing
ClapSerde with plain serde. Instead the `Config` still derives
`ClapSerde` (so `Config::default()`/`Opt` load the file), and the new
`config` helpers read/write it *without* `merge_clap` — argv is parsed
by `cbd_cli::TuiCli`/`CbdCli` and applied via `apply_overrides`. This
keeps the on-disk schema byte-identical to what `init_config` wrote
(verified: the file is `[server]`-nested; the README example was
corrected to match).
- **Connection flags are top-level, not clap-`global`.** They must come
before the subcommand (`cbd-tui --address X queue show`). Making them
`global=true` would collide with `auth`'s own `--address`. Documented
in the README.
- **`Box<dyn Error>` for CLI reports**, not color-eyre, following the
existing binaries' convention; errors are printed as one line with a
non-zero exit.
- **No fake-tonic-server test.** Coverage is argument-parse tests
(`cbd-cli`), config-writer round-trips (`settings`, `cbd-tui::config`),
and `scan`/`ingest_file` behaviour (`crabidy_server::cli`,
`crabidy_store`). The unreachable-server error path was verified
manually (concise message, exit 1).
## fyyd podcast provider (2026-07-23)
New `/fyyd` provider (crate `fyyd`) for finding and playing podcasts via
fyyd's keyless public API. Ran the full dev-flow pipeline; artifacts:
`architecture/fyyd-provider.md`, `quality/fyyd-provider.md`,
`plan/fyyd-provider.md`. Modelled on `ytdy` (remote, search-driven,
in-memory terms), wired into `ProviderOrchestrator` with the same
five-place pattern as every other provider: settings toggle, an
owns-check plus `fyyd_provider()`, a `build()` block, a root child, and
eight dispatch arms. No proto change, no new `ProviderCommand`, and no
audio-player change:
episode `enclosure` URLs feed straight into the existing HTTP-streaming
path.
Where the implementation shaped decisions beyond the plan:
- **Extra tree level (the defining difference from `/youtube`).** A
podcast search returns *podcasts*, each a container of *episodes*, so
the tree is `search-term → podcast → episodes-as-tracks` (one level
deeper than YouTube). A podcast node maps to ytdy's *playlist* (a
queueable/downloadable container of tracks); a search-term node maps to
ytdy's *playlists* listing (containers as children). The `search` and
`hot` branches share the podcast-node and episode-leaf builders.
- **`/fyyd/hot` added.** Not in the original one-line request, but fyyd's
`/feature/podcast/hot` gives a zero-typing browse for free, so the root
exposes `search` (creatable) **and** `hot` (fixed). Documented as D3.
- **HTTP behind a `Fyyd` trait** (`fyyd/src/api.rs`), faked in tests, so
all 10 provider unit tests run with no network — same seam pattern as
ytdy's `Extract`. Production `FyydApi` uses the workspace `reqwest`
(json/query/rustls), unwraps fyyd's `data` envelope, and decodes DTOs
defensively (`#[serde(default)]`, drop id-less entries, non-positive
durations → `None`).
- **Artist backfill.** A track's `artist` is the podcast title. Listing
episodes under a podcast already yields the title, so listed tracks get
it free; a *directly* fetched episode (`/episode`) carries no podcast
title, so `FyydApi::episode` does one extra best-effort `/podcast`
lookup (failure → `None`, never fatal). D4.
- **Non-fatal init, no credentials.** Unlike tidal, a missing `fyyd.toml`
is normal and a build failure only drops `/fyyd`. Every call is
timeout-bounded and every listing capped (`search_results`,
`hot_count`, `episodes_per_podcast`).
Deferred: **live validation** against the real `api.fyyd.de` (field
names/envelope were taken from the current public docs, not exercised on
the network). Left as an open gate in `quality/fyyd-provider.md` and a
`- [ ]` in `plan/fyyd-provider.md`; if a field differs the fix is
confined to the `FyydApi` DTOs.
Verification: `fyyd` 10 tests + `crabidy-server` 74 lib + 4 integration
green; clippy and rustfmt clean on both crates.