71 KiB
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 = trueis a fourth, mutually exclusive playable;Playable::Skipped,from_track_skipped, and a new wire flagTrack.is_skipped(set byto_track).from_trackpreserves skipped-ness, so persisted queues and bookmarks keep the marking instead of degrading it into a dead link.get_urls_for_trackon a skipped file is a typedFetchError. - 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::Downloadwrites straight intocaptures/<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
CaptureProgressupdate on the stream (name, download, done/total/skipped, terminal finished/error).CaptureLibraryNodereplies 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.
playskipsis_skippedtracks 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
CaptureBoardrenders 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. TheWhelp 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 EngineErrors — 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 byvalidate_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_namewas refactored onto a sharedordered_namehelper rather than duplicated fordir_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_tracknever follows a link, so chains die at play time and cycles cannot recurse.architecture/fs-provider.mdreconciled. SaveQueueErrorgainedDisabledandStatevariants beyond the stub:Disabled(no usable queues directory) maps tofailed_preconditioninstead of masquerading as I/O;Statecovers sidecar serialization.- The orchestrator mounts
/queuesindependently ofQueueStore: both derive the directory fromqueue_store::queues_dir(), so a mount over a not-yet-created folder simply lists as missing until the store (created inmain) writes it. No plumbing between the two. - Shuffle order is not persisted (documented in D3/D4 but worth
repeating): restoring
shuffle = truereshuffles around the restored current track. - The live probe needed no bespoke server run: provider-layer clients
plus
QueueStorecover 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.tomlwas too generic; thecbd-prefix makes the files unmistakably crabidy's. TrackFileError::UrlSchemecarries 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):
fsdyandtidaldyclients 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_nodeon a track path isMalformedPath— 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 intarget/); 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_intobecamestream_track_pages_into: the planned genericAsyncFnMutpage sink dies on a rustc "implementation ofSendis not general enough" limitation insideasync_traitmethods. The concrete method (fixedTrackitem 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 intoPendingResolve. - 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_selectednow gates the bare selection onis_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 testqueue_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 intarget/); 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_termimplementation deleted a term renamed to itself (the merge branch removed the "old" slot). Fixed with an explicitold != newguard; the architecture text ("merge on collision") now implicitly means distinct titles. pane_bindings_only_match_their_own_pane(help-modal suite) updated: it asserted plaindis unbound in the library — now it isLibraryDeleteNodeby design; the test's queue-only example key moved toc.delete_library_nodealso mapsInvalidInput→invalid_argumentalthough 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 intarget/); 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::updatenow 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_nodeno longer requires a user id up front — the gate moved into the favorites arms (planned), which also meanscreate_lib_nodevalidation 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-localCARGO_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 theScope::Helpbindings 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. ScopederivesHash(not in the stub) so the chord-uniqueness test can use aHashSet.QueueInsertHeredescription reworded to "Insert library selection after this track":crabidy-server'sinsert_trackssplices atposition + 1. Same check confirmed the planned "Queue selection after current track" wording forLibraryQueueNext.main.rspassestxtoApp::newwithout the now-unneeded clone; theKeyCode/KeyModifiers/UiFocus/StatefulListimports 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 theis_downloadableinheritance so noTrackliteral anywhere had to change.DeleteLibraryNodedoc extended to tracks and recursive folders. - fsdy:
with_deletable_tree()(only the/capturesinstance sets it): nested folders delete recursively; track deletes remove the toml plus its[playable] fileaudio 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_deletablefromtracks_deletable;dunder/capturesopens a modal reddelete <title>? [y/N]line (onlyy/Ysends, 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— orderedRole,minimum_roledefault-deny method table (pinned by a 24-method test),Authenticator(argon2 verify, success-only credential cache, indistinguishable failures),AuthLayer/AuthServicetower layer answering trailers-onlyUNAUTHENTICATED/PERMISSION_DENIEDviaStatus::into_http(), andhash_passwordfor the newcrabidy-server hash-passwordsubcommand (clap, stdin → PHC).cbd-tui—user/passwordconfig options and flags;AuthInterceptorbaking the Basic header into every request viaCrabidyServiceClient::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.mdgates 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 samecrabidy-coregenerated 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--accentcrab orange-red withcolor-mixderivations, light/dark viacolor-scheme+light-dark()plus a persisted toggle, phone breakpoint.
crabidy-server changes:
web-uicargo feature (default on);--no-default-features= headless gRPC-only.build.rsstagescbd-web/distintoOUT_DIR(or a placeholder page — plaincargo buildneeds no wasm toolchain), embedded viainclude_dir.web.rsserves the embedded bundle (GET/HEAD, index fallback).serve()refactored tobuild_router(): one axum router with the gRPC service (auth layer →tonic-webGrpcWebLayer → service) as a route and the web bundle as fallback;axum::servereplacestonic::transport::Server.
Cross-cutting:
- crabidy-core builds for
wasm32-unknown-unknown: workspacetonicsetdefault-features = false, this crate takes codegen-only, native binaries re-enable transport/router/channel;build.rsusesbuild_transport(false); config loading gated to non-wasm. - devenv:
trunk,wasm-bindgen-cli,binaryen, the wasm target, andbuild-web/serve-webscripts (which clearRUSTFLAGS— the mold linker flag breaksrust-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 fromserve()(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
Filterhelper incbd-tui/src/app/list.rs: keeps the pane's full list, records visible real indices, maps view↔real. Both panes route selection, marks, rendering, andStatefulListsize 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>andhandle_search_key(type = live filter, Enter keeps, Esc clears), anOpenSearchaction 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 2048f32slots, atomic write index doubling as an idle counter) andTappingSource, 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_seekdelegated so seeking still works. Exposed viaPlayer::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 newStreamUpdate::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 onGetUpdateStream. - cbd-tui: a bar row (block glyphs
▁..█, accent color) under the progress gauge in the now-playing pane;spectrumconfig option (default true) on bothcbd-tui.tomlandcbd.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_tracknow routes a local-file source to a newcopy_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. Won the queue (#3,cbd-tui): the queue pane bound onlyw(save); capturing the queue meant save-then-navigate-then-W. AddedQueueDownloadCaptureon Shift-Win 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,StoreNamevalidation),from_track_store,Client::with_store_rootand store resolution inget_urls_for_track;to_trackmarks store playables captured.AlbumMetagainedClone.crabidy-server/src/crabidy_store.rs(new):CrabidyStoreowns 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.tomlat open, updated on write.save(Link/Capture) enumerates the source into a temp folder and swaps it in atomically (conflict → refuse).capture_trackruns 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_snapshotis the queue-wlink-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. RemovedSink/capture_into/write_links/fetch_track/copy_local/existing_is_satisfiedandaudio_file_name.CaptureErrorgainedConflictandStore(StoreError).- Orchestrator: one
crabidy_client+crabidy_storereplacing the three fields; singlecrabidy_ownsrouting arm across all methods;get_lib_nodecallsannotate_capturedso captured tracks are marked in any provider's listing;crabidy_store()accessor shares the Arc with playback. rpc.rs:capture_error_statushelper;save_queuenow link-saves the live queue into/crabidy.playback.rs: persists/restores viaCrabidyStore.cbd-tui:/crabidy/currentpath; captured|row marker; delete confirmation removed (deletes go direct, never touch the store).- Providers: tidal sets
provider_item_idto the Tidal track id, youtube to the video id. - Docs: superseded
bookmarks.md/captures.md/capture-deletion.md; README config/dirs table, singlecrabidyprovider,|marker, direct deletes.
Deviations from the plan/architecture:
SaveQueueRPC kept (architecture D6 said remove it). Reimplemented server-side as a Link save of the live queue into/crabidyviaCrabidyStore::save_snapshot; queue-wstill uses it, queue-WusesCaptureLibraryNode(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_idleft empty (D3 said canonical path). fs de-duplicates by content hash instead;to_trackhas no disk context to resolve a relativefileplayable 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_capturedmarks 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/crabidyare marked directly byto_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
fsdychanges landed as real code andcrabidy_store.rswas 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-cliexecutor (clientfeature):run_remotedispatches everyLibraryCmd/QueueCmd/GlobalCmdvariant to its gRPC call, mirroringcbd-tui'sRpcClientrequest construction (with a directStopcall, 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/Statuserrors map to a single-line message (no color-eyre chain).- Config writers:
ServerSettings::storeround-trips[auth](skip_serializing_ifkeeps the flat shape parseable underdeny_unknown_fields);cbd-tui'sconfigmodule gainedload_first_run,apply_overrides,store, andwrite_auth(plus path-based_atvariants for tests). crabidy-server:mainnow parsescbd_cli::ServerCli; a newcrabidy_server::climodule holdsguard,scan,scan_dir,write_role_hash, andconnection.guardprints the PHC hash to stdout (pipeable) and the confirmation to stderr.scanwalks a folder (bounded, hidden/symlink-skipped) writing.cbd-track.tomlsidecars;--capture/--moveuse the newCrabidyStore::ingest_file.hash-passwordwas removed (guard <role> --no-configreplaces it).CrabidyStore::ingest_file: the local-source half ofcapture_track, factored out — hash, de-dup by content hash, copy or move into the store, write the sidecar, return the store name.- Clients:
cbd-tuiandcbdparse theircbd_cliCLIs; the no-subcommand path loads the TOML config (defaults written on first run) and applies the flag overrides, then runs as before.authwrites the client config;library/queue/globalcallrun_remote. - Assets: each binary crate has a
build.rsthat build-depends oncbd-cli(default features only) and writes completions + a man page intoOUT_DIR, and into$CBD_ASSET_DIRwhen set. A devenvgen-cli-assetsscript producesdist/completions/**anddist/man.
Deviations
- ClapSerde kept, not replaced. Architecture D2 floated replacing
ClapSerde with plain serde. Instead the
Configstill derivesClapSerde(soConfig::default()/Optload the file), and the newconfighelpers read/write it withoutmerge_clap— argv is parsed bycbd_cli::TuiCli/CbdCliand applied viaapply_overrides. This keeps the on-disk schema byte-identical to whatinit_configwrote (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 themglobal=truewould collide withauth'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), andscan/ingest_filebehaviour (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 issearch-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). Thesearchandhotbranches share the podcast-node and episode-leaf builders. -
/fyyd/hotadded. Not in the original one-line request, but fyyd's/feature/podcast/hotgives a zero-typing browse for free, so the root exposessearch(creatable) andhot(fixed). Documented as D3. -
HTTP behind a
Fyydtrait (fyyd/src/api.rs), faked in tests, so all 10 provider unit tests run with no network — same seam pattern as ytdy'sExtract. ProductionFyydApiuses the workspacereqwest(json/query/rustls), unwraps fyyd'sdataenvelope, and decodes DTOs defensively (#[serde(default)], drop id-less entries, non-positive durations →None). -
Artist backfill. A track's
artistis 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, soFyydApi::episodedoes one extra best-effort/podcastlookup (failure →None, never fatal). D4. -
Non-fatal init, no credentials. Unlike tidal, a missing
fyyd.tomlis 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/incompleteabs.tomldisables/absnon-fatally (like fyyd/youtube on a failed probe), it does not stay fatal like tidal. Theapi_keyand the?token=stream URL are secrets:SettingsandAbsApihave manual redactingDebug, and the token is built only inAbs::stream_url— never logged, never handed to areqwestcall insideabsdy(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>), soget_urls_for_trackbuilds 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 reservedsearchsegment splits the search branch from item ids (UUIDs never equalsearch). -
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 onlybooklibraries (podcast libraries out of scope, D6). -
get_lib_rootis a placeholder. Listing ABS libraries needs a network call, but the trait'sget_lib_rootis sync; the real/absroot is served by the asyncget_lib_node. The orchestrator only builds the global-root link fromPROVIDER_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 rodioSourcethat demuxes Ogg with symphonia's own Ogg reader and decodes Opus viasymphonia-adapter-libopus(libopus, registered into an explicit codec registry — rodio'sDecoderwon't take a custom registry, so opus is driven directly). Mirrors rodio 0.22'sSymphoniaDecoderloop (channels/rate/ duration/seek). First packet decoded up front so the struct always holds a real spec (symphonia'sSampleBuffer::newdivides by channel count).player_engine.rs:open_sourcerefactored into a sharedbuild_sourcethat sniffs the first 64 bytes (OggS+OpusHead), rewinds, and routes opus toOpusSource, 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
soundclouddycrate stubs (Scseam,Client,ScPath+realparse_path) +audio-player/src/hls.rs(HlsStreamskeleton). - 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 theScseam. - 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), batchedhydrate_tracks, andinitthat scrapes if needed and persists the id. - C
audio-player/src/hls.rsHlsStream(SourceStream): fetches the m3u8, follows one master level, streams mp3 segments in order; forward-only, length-less, non-seekable.parse_playlistunit-tested.open_sourceroutes.m3u8→ HlsStream (non-seekable so symphonia never end-seeks);build_sourcegained aseekableflag. - D server wiring:
soundclouddydep,settings.rs(ALL_PROVIDERS→8,ProviderToggles.soundcloud),provider.rs(sc_client,sc_owns,sc_provider, non-fatalbuildblock, root child, all 8 dispatch arms). - E
soundclouddy/tests/live.rs(#[ignore], env-gated); fmt/clippy/machete clean; dropped unusedserde_json.
- A provider tree/path logic (
Verified offline: soundclouddy 19 tests + 4 api-parser tests, audio-player 14
(incl. 3 HLS-parser), crabidy-server 77+4 — all green; cargo build -p crabidy-server clean. Needs live SoundCloud to confirm (no creds/audio in
sandbox): the actual client_id scrape, live API JSON shapes, and mp3-HLS
play-to-EOS — all exercised by tests/live.rs and the #[ignore] gates
(quality G7/G8/G14/G19). Run those on a real machine.
jamendo provider — /jamendo (2026-07-24)
Ran the full dev-flow pipeline autonomously for a /jamendo provider (free /
Creative-Commons music via the official api.jamendo.com/v3.0). Artifacts:
architecture/jamendo-provider.md (D1–D6, 2 diagrams), quality/jamendo.md
(T1–T11 + G1–G10), plan/jamendo.md. New crate jamendody, shaped on
soundclouddy but the simple case — Jamendo has a stable official API, so
there is no client_id scraping, no OAuth, and no HLS.
- Tree:
/jamendo→search(creatable) →/jamendo/search/<term>listing tracks (canonical/jamendo/track/<id>rows) + albums (canonical queueable/jamendo/album/<id>containers) →/jamendo/album/<id>tracks. Same search-term store + download-blessing pattern as the other providers. - Playback with no player change:
get_urls_for_trackreturns the track's directaudioMP3 URL, played on the existing windowed-HTTP + symphonia path. Jamreqwest seam (api.rs, faked in tests):search_tracks,search_albums,album_tracks,track_detail,track_stream. Per-call timeout,limitclamped to Jamendo's 200,client_idredacted fromDebug, defensive#[serde(default)]DTOs over the{headers, results}envelope (HTTP-200 error envelopes mapped to typedFetchError).- Duration stays in seconds — Jamendo's native unit is
Track.duration's, so it passes through unchanged (a test guards against a ms/seconds regression). - Server wiring (the standard pattern):
jamendoinALL_PROVIDERS(8→9),ProviderToggles.jamendo;jamendo_clientfield,jamendo_owns,jamendo_provider, a non-fatalbuild()block (missing/emptyclient_iddisables/jamendoonly), a root child, and dispatch arms in all methods (is_track_path,get_lib_node,get_urls_for_track,get_metadata_for_track,resolve_tracks_into, create/rename/delete).
Deviations: stream URL is a dedicated track_stream seam method (not a field on
JamTrack), matching soundcloud's metadata/URL separation; client_id required
with no init-time network (a bad key surfaces lazily as FetchError);
audioformat defaults to mp32 (higher bitrate) over Jamendo's mp31.
Verified offline: jamendody 16 tests (T1–T11 + 3 DTO-decode) green,
crabidy-server tests green (settings iterate ALL_PROVIDERS, no edit needed),
cargo build -p jamendody -p crabidy-server clean, clippy -D warnings + fmt
clean. Deferred live gate (R1): with a real client_id in jamendo.toml,
browse → queue → play a track and confirm the audio MP3 streams with a correct
seek bar and clean EOS. Needs network + a registered key; not runnable here.
TUI visual mode (2026-07-24)
Full dev-flow run for a vim-style visual (paint-select) mode in the library
pane (architecture/visual-mode.md, quality/visual-mode.md, plan/visual-mode.md).
v or V enters it (both the same); movement then toggles the mark of every row
swept over, so a run is selected by v then moving. A second v/V or Esc
leaves it, marks kept.
- bindings.rs: new
Action::LibraryVisualModebound tov(NONE) andV(SHIFT) in the Library scope; the spectrum toggle moved offvto Globalf("frequency") to free the key. Help text/labels derive fromBINDINGSunchanged. - library.rs: a
visual: boolwithis_visual/toggle_visual(enter toggles the current row, vim-style)/exit_visual,selected_view, andpaint_between(from_view, to_view)— toggles the mark of every view index in the half-open sweep(from, to], mapped through the/filter and gated onis_queableexactly likes.toggle_markrefactored onto a sharedtoggle_mark_view. Title shows— VISUALwhile active. - mod.rs:
App::dispatchcaptureswas_visual, auto-leaves visual mode for any action except the six movements,LibraryVisualMode, andClearSearch; the six movements route through alibrary_movehelper that paints the swept range when visual is active;Escin visual leaves the mode without clearing the/filter; node/focus changes leave it.
Decisions/deviations: paint is anchored — the selection is the contiguous
range [anchor, cursor], and a move toggles the rows whose range membership
changed, so moving back cleanly reverses (a first per-step-toggle attempt
stranded the turnaround row — fixed); library-only — the queue has no marks
yet (a standing queue.rs FIXME), so v/V are unbound there (D5); spectrum
key f is a chosen default, trivially changed; web-client parity deferred
(D6). Jump moves (g/G, Ctrl-d/Ctrl-u) reconcile the whole span.
Verification: cbd-tui 89 tests green (14 new: 2 binding + 12 dispatch/paint
covering enter/step/jump/back-sweep/exit/Esc/non-move-exit/node-focus-exit/
is_queable/filter); clippy -D warnings and fmt clean; markdownlint clean. Not
exercised: live in-terminal keypresses (no TTY here) — the pure dispatch/table
logic is fully unit-tested and the render path compiles.
build features (2026-07-25)
Guarded each provider, Opus decoding, the spectrum, and desktop
notifications behind Cargo features (all default-on) so people can build a
non-bloated binary, with the dependency graph aligned to the flags. Ran the
full dev-flow: architecture/build-features.md (D1–D11),
quality/build-features.md (G1–G30), plan/build-features.md (A/B/C).
Three commits.
A — mount registry (crabidy-server/src/provider.rs). The orchestrator
held nine Option<Arc<ConcreteClient>> fields and repeated the same
if …_owns(path) chain across eight ProviderClient methods.
ProviderClient is dyn-compatible (init carries Self: Sized), so it now
holds mounts: Vec<Mount> of Arc<dyn ProviderClient> and every method is
one owner(path) lookup — ~600 lines of repetition gone, and a provider is
named in exactly one place, which is what made the feature work tractable.
Ordering (crabidy first, orphans last, rest alphabetical) moved to
from_mounts; the five config-file providers share a mount_from_config
helper. Behaviour-preserving, landed on its own with 6 new unit tests
(ownership boundaries, dispatch, typed errors for unowned paths, ordering,
the empty registry, dyn dispatch of an overridden resolve_tracks_into).
B — the features. crabidy-server: tidal, youtube, fyyd, abs,
soundcloud, jamendo, fs, opus, spectrum, web-ui, plus
all-providers; cbd forwards all of them and adds notifications;
cbd-tui owns notifications; audio-player owns opus. Gated deps are
optional = true, and the three feature-carrying local crates get
default-features = false in [workspace.dependencies] (a member cannot
drop an inherited default).
Answers to the two questions in the request: scan did already treat
.opus as playable; that entry is now conditional on opus, so a build
that cannot decode Opus will not index it either. And fs off does take
/crabidy and /orphans with it.
Decisions taken autonomously (dev-flow, no questions):
- A feature must pay for itself in dependencies. That is why
crabidyandorphansare not separate features (no dependency of their own —crabidy_store/capture/orphansare written againstfsdy), and whyhls.rs,windowed_http.rs, andspectrum_tap.rsstay unconditional. Finer-grained control already exists at runtime in theproviderslist. fsis one coherent unit:/fs, the content store,/crabidy,/orphans, bookmarks/captures, queue persistence, andscan. Without itCaptureLibraryNode/SaveQueueanswerUnimplementedandscanreports the missing feature — no panics, no hangs.[auth]/argon2 is deliberately not gated (D9): a build that ignored configured role hashes would run an intended-to-be-locked server open. Refused as a fail-open hole for a small dependency.- The compile-time set bounds the runtime one:
BUILT_IN_PROVIDERSfiltersprovider_enabled, the auto-written default config lists only built-ins, and aprovidersentry this binary lacks logs one warning (fail-open, unlike auth).crabidy-server features/cbd featuresprint the compiled set and startup logs it. - An internal
_any-providermarker feature (every provider enables it) expresses "is any provider compiled in?", which Cargo features cannot; it keeps the legal-but-degenerate zero-provider build warning-free without blanketallows. flake.nixhad to change in the same breath: its native package passed a bare--no-default-features(meaning "all butweb-ui"), which after this work would have shipped a provider-less binary. Now--features all-providers,opus,spectrum,notifications.
Verification: devenv shell -- check-features (new script) runs the curated
matrix — defaults, --no-default-features, each of the seven providers
alone, each extra dropped, the two documented examples, audio-player,
cbd-tui, cbd — all clippy-clean under -D warnings, with tests at both
extremes. Full default suite green (88 server + 90 tui + the rest). G1
verified mechanically: the default cargo tree -p crabidy-server set is
identical to the pre-change tree. Confirmed by inspection of cargo tree
that --no-default-features drops every gated crate, opus-off drops
opusic-sys/symphonia-adapter-libopus, and cbd-tui --no-default-features drops notify-rust. Smoke-ran both the
provider-less and the fs,opus binaries under isolated XDG dirs: startup
logs the feature list, writes a default config listing only built-ins, and
warns once per unavailable name. Not exercised: nix build (not run here)
and playback of an Opus file in an opus-less build (no audio device).