Compare commits

...

50 Commits

Author SHA1 Message Date
Hans Mündelein e2c1b44cdb
Fix sccache coflict 2026-07-25 13:29:26 +02:00
Test User d630c9e550 cbd-web: drop the unreachable capture-delete confirmation
delete_needs_confirmation only ever returned true for /captures paths,
and /captures stopped existing when saved queues, bookmarks, and captures
folded into the single /crabidy provider. So the y/N dialog could not
open, the web client already deleted immediately, and its comment
claiming to mirror the TUI described an arrangement neither client had.

Removed rather than re-pointed at /crabidy: a delete there drops the
metadata toml only, never the shared store audio, which survives and
resurfaces under /orphans — so there is little to guard. Both clients now
behave the same, which is what the docs describe.

Gone with it: the Dialog::ConfirmDelete variant, the ConfirmDialog
component and its keyboard handler, the test, and the two CSS rules only
that dialog wore (.danger-dialog and the solid .danger button;
.ghost.danger stays, three row actions still use it).

Verified: clippy clean for wasm32 (where `mod app` actually compiles) and
native, 12 cbd-web tests pass, and the trunk bundle builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:58:15 +02:00
Test User ba4775b0bb nix, cross: give the builds cmake so the bundled libopus compiles
nix build .#crabidy-server-aarch64 and .#crabidy have both been broken
since Opus decoding landed: symphonia-adapter-libopus pulls opusic-sys,
which compiles a bundled libopus with CMake. devenv.nix got cmake at the
time, flake.nix never did, so the derivation died with "is `cmake` not
installed?" while building the deps.

Both flake derivations now carry pkgs.cmake, and so do the three cross
Dockerfiles, which had the identical gap. No ninja anywhere: its mere
presence flips cmake's generator and then clashes with a build dir cached
under the other one.

Verified: nix build .#crabidy-server-aarch64 completes, and its output is
a 39 MB statically linked aarch64 ELF with every provider, the embedded
wasm bundle, and the Opus decoder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:37:01 +02:00
Test User ab3bd7c63a docs: bring the book and every README up to date
The docs drifted behind three changes: the /queues + /bookmarks +
/captures split folding into one /crabidy provider, three providers
arriving (soundcloud, jamendo, abs) with nothing written about them, and
the spectrum toggle moving off v to f when library visual mode took v/V.

- The book gains a page per undocumented provider — /soundcloud,
  /jamendo, /abs — each with its tree, its playback path, every config
  option, and how to log in. The providers index and intro list all nine
  roots in the order the server actually serves them.
- Every provider now documents its login: Tidal's device flow (and that
  a broken tidaly.toml is the one fatal provider config), audiobookshelf
  API keys, the optional SoundCloud token and where to read it out of a
  browser, YouTube cookie exports, Jamendo's shipped key, and "nothing
  to do" for fyyd and /fs.
- fsdy's README described three server-managed mounts under
  ~/.config/crabidy that have not existed for a while; it now describes
  /crabidy over the state dir plus the shared content store, and how
  deletes there never touch store audio.
- Stale /captures/<name> save paths in the tidaldy and ytdy READMEs are
  /crabidy/<name>. The TUI key table, the README walkthrough, and the
  spectrum section use f, and visual mode (v/V) is documented.
- config.md and the README list all seven provider config files, say
  plainly that credentials are stored in cleartext, and cover the audio
  output device; the CLI page documents audio-devices and features.
- No README or docs page references architecture/, quality/, or plan/
  any more: the book describes the system as it is, and points at the
  crate READMEs for usage and config.
- devenv-docs.nix was never committed even though devenv.nix imports it,
  so a fresh clone could not enter the shell at all. It is in now, which
  also makes the README's `devenv shell -- docs` work.

Also fixes two ./store.md links in providers/fs.md that pointed one
directory too shallow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:04:25 +02:00
Test User af04573b72 jamendo: ship a default client_id so /jamendo works out of the box
The app key identifies the application, not a user, so there is no reason
to make everyone register one before they can play anything. An unset (or
blank) client_id now falls back to DEFAULT_CLIENT_ID, and init writes
whichever key is in force back into jamendo.toml, so the effective value
is always visible and replaceable.

Jamendo rate-limits per key, which the docs say plainly: a shipped
default is a shared budget, and a heavy user should register their own.
A configured key always wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:54:50 +02:00
Test User cd3a16f95c docs, flake: document tailored builds and name the flake features
flake.nix first: its native package passed a bare --no-default-features,
which used to mean "everything but web-ui" and now means *no providers
at all*. It names its set explicitly
(all-providers,opus,spectrum,notifications); the aarch64 cross build
keeps the full defaults and its staged wasm bundle.

docs/src/build-features.md: the feature table with what each one costs to
lose, why fs takes /crabidy, /orphans, queue persistence and scan with
it, the opus/libopus build note, two worked examples, what is
deliberately not gated, and check-features. Linked from SUMMARY.md, and
config.md now says the providers list can only offer what the binary was
built with. README gains a short pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 03:18:50 +02:00
Test User a03e3de84e build: put every provider, opus, and the spectrum behind cargo features
All on by default, so a plain build is unchanged (verified: the default
dependency set for crabidy-server is byte-identical to before). Tailor a
smaller binary with --no-default-features --features …
(architecture/build-features.md).

Compile-time features draw dependency boundaries; the existing
crabidy-server.toml providers list keeps doing per-mount runtime
toggling. The compile-time set bounds the runtime one: a provider built
out cannot be enabled from the config, and naming it earns one startup
warning rather than silence.

- crabidy-server: tidal · youtube · fyyd · abs · soundcloud · jamendo ·
  fs · opus · spectrum · web-ui, plus the all-providers group.
- fs is local files *and* persistent state (D5): the /fs mount, the
  content store behind /crabidy and /orphans, bookmarks/captures, queue
  persistence, and scan. Without it Capture/SaveQueue answer
  Unimplemented and scan says which feature is missing — never a panic.
- opus drops symphonia + symphonia-adapter-libopus, and with them the
  bundled libopus C build (no more cmake requirement). It also decides
  whether scan indexes .opus at all, so scan never indexes what this
  build cannot play. An Ogg-Opus file in an opus-less build reports the
  missing feature and is skipped like any undecodable file.
- spectrum drops realfft and the FFT task; clients just never receive a
  frame. cbd-tui gains notifications (notify-rust, a D-Bus stack).
- crabidy-server/cbd features print the compiled set, and startup logs
  it, so a tailored binary is self-describing.

Not gated, deliberately: [auth]/argon2 (a build ignoring configured
hashes would run open — fail-open security hole), and hls.rs /
spectrum_tap.rs / windowed_http.rs (no dependency of their own, so
gating them buys cfg noise and nothing else).

devenv gains check-features: the curated matrix (defaults, nothing, each
provider alone, each axis dropped, both worked examples, the client
crates) all clippy-clean under -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 03:15:32 +02:00
Test User 1b838578e2 server: dispatch providers through a mount registry
The orchestrator held nine Option<Arc<ConcreteClient>> fields and
repeated the same if-owns-this-path chain across eight ProviderClient
methods. ProviderClient is dyn-compatible (init carries Self: Sized), so
mounts are now Arc<dyn ProviderClient> in one Vec<Mount>, and each
method is a single owner lookup.

Behaviour is unchanged: same owner boundaries (/fsx is still not /fs),
same MalformedPath for lookups and NotSupported for mutations, same root
ordering (crabidy first, orphans last, rest alphabetical) now done once
at build time, same annotate_captured on get_lib_node. The five
config-file providers that only differ in their file and root share one
mount_from_config helper.

This is the groundwork for putting each provider behind a build feature
(architecture/build-features.md): a provider is now named in exactly one
place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:59:38 +02:00
Test User 6f3b60254e server: order library root as crabidy first, orphans last, rest alphabetical
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:46:58 +02:00
Test User 2e94760a69 cbd-tui: fix visual mode stranding the turnaround row (anchor the range)
The per-step paint toggled the row you arrived at, so going down then
back up toggled off the rows re-entered but never the furthest row you
turned around on — it stayed marked. Anchor the selection instead: on
entering visual mode record the anchor row, and on each move reconcile
marks to the contiguous range [anchor, cursor], toggling only the rows
whose membership changed. Moving back now cleanly reverses; jumps
reconcile the whole span. visual state becomes Option<usize> (the
anchor). Adds a regression test (down then fully up leaves only the
anchor); 90 cbd-tui tests green, clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:29:26 +02:00
Test User 0a9456e173 cbd-tui: add library visual (paint-select) mode on v/V; move spectrum to f
Press v or V (both the same) to enter visual mode in the library pane;
movement then toggles the mark of every row it sweeps over, so a run of
items is selected by v then moving (g/G and Ctrl-d/u paint the whole
span). Entering toggles the current row (vim-style); a second v/V or Esc
leaves the mode with marks kept, and any other action leaves it first
then runs. This frees v, so the frequency-spectrum toggle moves from v
to f.

Painting reuses the existing marks (is_queable-gated, filter-mapped);
no wire, proto, or server change. Library-only for now — the queue has
no marks yet. Ran the full dev-flow; artifacts under architecture/,
quality/, plan/. 89 cbd-tui tests green (14 new); clippy and fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:20:55 +02:00
Test User 238e7c8f87 jamendo: default to mp31 streaming and fall back when a format has no audio
The mp32 audioformat is not reliably provisioned for the streaming
audio URL — Jamendo returns an empty audio field for many tracks — so
mp32 as the default made playback fail with track is not streamable
even though metadata resolved. Default to mp31 (the freely streamable
MP3), and have track_stream retry without a forced format when the
configured one yields no audio, so a Pro-only format degrades to a
playable stream instead of skipping the track. Live-verified: default
config resolves a stream URL, and an explicit mp32 now falls back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:02:13 +02:00
Test User 7df5f0a1c2 jamendo: send a User-Agent (Jamendo returns empty results without one)
Jamendo API v3.0 answers HTTP 200 success with an empty result set to
any request that carries no User-Agent header. reqwest sends none by
default, so every search/detail/stream call came back empty and tracks
would not resolve or play (jamendo resource not found). Set a UA on the
JamApi client, like the SoundCloud provider does. Live-verified against
the API: search, track detail, and stream-URL resolution all return data
with the header present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:54:31 +02:00
Test User 06381949ab Add the Jamendo provider (/jamendo) for Creative-Commons music
New jamendody crate implementing ProviderClient, mounted at /jamendo:
search the Jamendo catalogue and play tracks, browse an album a track
belongs to, with captures/downloads for free.

Unlike the SoundCloud provider this is the simple case — Jamendo has a
stable official API (api.jamendo.com/v3.0), so there is no client_id
scraping, no OAuth, and no HLS: a track streams via its direct audio MP3
URL on the existing windowed-HTTP path, and duration is already in
seconds (matching Track.duration). A registered client_id in jamendo.toml
is required; missing it disables /jamendo only, non-fatally.

Ran the full dev-flow pipeline; artifacts under architecture/, quality/,
and plan/. 16 jamendody unit tests over a faked Jam network seam; server
wired with the standard owns/provider/build/dispatch pattern and a
jamendo toggle in ALL_PROVIDERS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:43:05 +02:00
Test User c4001df74d web: show track times in mm:ss (or h:mm:ss), matching the TUI
The now-playing clock rendered raw milliseconds as seconds, so a 3:04
track read as 3070:32. TrackPosition carries milliseconds (the TUI wraps
it with Duration::from_millis); convert ms to seconds at both display
sites. The progress gauge already used a position/duration ratio, so it
was unaffected. format_seconds now zero-pads minutes and rolls into
h:mm:ss past an hour, matching the TUI's now-playing pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:23:20 +02:00
Test User 739c5a805a soundcloud: prefer the progressive mp3 stream (HLS exchange 404s anonymously)
Live testing showed SoundCloud only serves the progressive+audio/mpeg
transcoding to anonymous clients: its media exchange returns 200 with a direct,
range-streamable mp3 URL (cf-media.sndcdn.com, 206, audio/mpeg), while the plain
hls+audio/mpeg exchange 404s for every track (streamable or not). The provider
picked HLS, so every queued track failed to resolve a URL and was skipped.

pick_stream_url now prefers progressive, falling back to hls. A progressive URL
is a plain mp3 the player streams on its normal windowed-HTTP path (no .m3u8, so
HlsStream is bypassed); HlsStream stays the fallback for HLS-only tracks. A
genuinely restricted track (Go+/label preview, geo-blocked) still 404s the
exchange and is skipped, not crashed. Note: SoundCloud login does not help here
- public streaming is client_id-only.

Verified live: search a streamable track -> resolve -> 206 range GET returns
audio/mpeg with an mp3 frame-sync header. 19 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 02:47:13 +02:00
Test User 6abda3aa58 devenv: drop ninja so cmake's opusic-sys generator is deterministic
Adding ninja alongside cmake let the cmake crate auto-select the Ninja
generator whenever ninja happened to be on PATH. A build dir first cached under
Make (ninja absent) then rebuilt with ninja present fails with "Does not match
the generator used previously: Unix Makefiles". gnumake is already provided by
stdenv, so keeping only cmake pins the generator to Make everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 02:33:15 +02:00
Test User 7091d37c32 Add the SoundCloud provider (`/soundcloud`) with HLS playback
New `soundclouddy` crate mounted at `/soundcloud`: search tracks and
playlists, resolve permalink URLs, and — with an optional OAuth token — the
user's likes and playlists (public browse/play needs only a client_id). Ran the
full dev-flow: architecture/soundcloud-provider.md, quality/soundcloud-provider.md,
plan/soundcloud.md, plan/summary.md.

- Provider logic over an `Sc` reqwest seam (faked in tests): creatable
  `search`/`resolve` parents, canonical `track/<id>` and `playlist/<id>`
  leaves, playlist hydration, download blessing — mirrors abs/fyyd.
- Auth: `client_id` from config or scraped from soundcloud.com (pure parsers,
  unit-tested), re-scraped once on 401; scraped id persisted via `settings()`.
- Playback: a new `HlsStream` SourceStream in audio-player streams the m3u8's
  mp3 segments in order as one continuous mp3; `open_source` routes `.m3u8` to
  it, non-seekable so symphonia never end-seeks a length-less stream.
- Wired into crabidy-server the standard way (settings toggle, sc_owns/
  sc_provider, non-fatal build block, root child, dispatch arms).

Verified offline: soundclouddy 19 tests, audio-player 14 (incl. HLS-parser),
crabidy-server 77+4 — all green; fmt/clippy/machete clean. The live client_id
scrape, real JSON shapes, and mp3-HLS play-to-EOS need real SoundCloud access
and are covered by tests/live.rs + #[ignore] gates (quality G7/G8/G14/G19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 02:27:10 +02:00
Test User 9fba9708bf Audio: decode Ogg-Opus via libopus so opus streams play
rodio decodes through symphonia 0.5, which ships no Opus decoder, so raw
.opus sources (audiobookshelf files, and opus from any provider) failed
Decoder::build(). Add OpusSource, a rodio Source that demuxes Ogg with
symphonia own Ogg reader and decodes with libopus (via
symphonia-adapter-libopus, registered into an explicit codec registry), and
route to it by content-sniffing OggS+OpusHead in the player -- the abs stream
URL has no file extension, so the extension hint is not enough. The bundled
libopus builds with cmake/ninja (added to devenv).

Verified end-to-end with ffmpeg mono/stereo opus fixtures including seeking;
11 audio-player tests pass, clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 02:10:02 +02:00
Test User f4309aa327 Add the audiobookshelf provider (`/abs`)
New `absdy` crate mounted at `/abs`: browse, search, and play audiobooks
from a self-hosted audiobookshelf server. Shaped on the fyyd provider --
an `Abs` reqwest seam faked in tests (14 unit tests, no network), an
in-memory per-library search-term store, and a `library -> book -> tracks`
tree with a per-library `search` subtree.

audiobookshelf-specific decisions:

- Credentials + a secret. A missing/incomplete abs.toml (no base_url or
  api_key) disables `/abs` non-fatally. The api_key and the `?token=`
  stream URL are secrets: Settings and AbsApi have manual redacting Debug,
  and the token is built only in `Abs::stream_url` -- never logged, never
  handed to a reqwest call in absdy (browse auth is a bearer header).

- Playback needs no API call: a track's stream URL is fully derivable from
  its path (item id + ino) plus the token. Verified live that `?token=`
  auth returns 200 and the file endpoint honors HTTP range (206), so the
  windowed-HTTP player streams it directly.

- Per-library search (ABS search is per-library); the reserved `search`
  segment splits the search branch from item ids. A book's queueability
  comes from the summary's numAudioFiles, so ebook-only items show but are
  not queueable. Root lists only book libraries.

Wired through the orchestrator and settings exactly like the other
providers (dispatch arms, root child, ALL_PROVIDERS, ProviderToggles).
An `#[ignore]`d live test (absdy/tests/live.rs) validates the DTOs against
a real server end-to-end. Docs: architecture/, quality/, plan/, READMEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:22:52 +02:00
Test User 11b2a1bd38 Architecture: audiobookshelf (`/abs`) provider design
Design doc for a new `absdy` provider mounted at `/abs` that browses,
searches, and plays audiobooks from a self-hosted audiobookshelf server.
Shaped on the fyyd provider: an `Abs` reqwest seam faked in tests, an
in-memory per-library search-term store, and a `library -> book -> tracks`
tree with a per-library `search` subtree.

Grounded live against the test server: bearer auth for browse, `?token=`
query auth plus HTTP range (206) on the file endpoint, so a track's stream
URL is fully derivable from its path with no extra call. The embedded token
and the api_key are secrets, redacted from logs/Debug (hard rule).

Also gitignores the abs-api-key file so the JWT never lands in a commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:09:54 +02:00
Test User 4e69261e02 Playback: play the current track when toggling/restarting from idle
After a restart the queue is restored with the right current track, but
playback does not autostart -- the audio engine has nothing loaded and
the play state is Stopped. Space (TogglePlay) called unpause() and r
(RestartTrack) called restart(), both of which error out with "not
playing" because no source is loaded; only switching to the queue and
pressing Enter (SetCurrent) actually started anything.

Make the resume-style controls load the current queue track when the
player is idle: TogglePlay now plays the current track on any non-
playing/paused state, and RestartTrack starts the current track when
nothing is loaded (and still restarts the loaded one otherwise). Both
route through the same play() path SetCurrent uses, and are a no-op when
the queue is empty. Paused/Playing behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:52:32 +02:00
Test User 28a4c155d0 Audio: let `audio-devices <device>` write the config
The audio-devices command only listed. Give it an optional positional
argument: with none it lists as before; with a device name (or
case-insensitive fragment) it writes that into [audio] device in
crabidy-server.toml and then lists, so the same command both configures
and confirms (the chosen device is marked with *). If the fragment
matches no current output device it still writes but warns, mirroring
the server's startup fallback -- so a typo is caught here, not as silent
output.

Plumbing: AudioDevices carries an AudioDevicesArgs { device: Option }
on both `crabidy-server` and `cbd`; cli::audio_devices takes the option
and, when set, loads/updates/stores the settings via the existing
ServerSettings writer. README shows the set form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:44:46 +02:00
Test User 4c0c5f1401 Audio: select the output device (fixes silent output on Raspberry Pi)
The player always opened the system default output device. On a
Raspberry Pi that default is often HDMI, so playback ran but nothing
came out of the headphone jack or a USB/DAC -- "it plays but I hear no
sound".

Add an [audio] device option to crabidy-server.toml: a case-insensitive
substring of the output device name (a memorable fragment is enough).
The player engine opens the first matching device and falls back to the
system default with a warning if none matches. Absent config keeps the
system default, so existing setups are unchanged.

To discover the names, a new `crabidy-server audio-devices` subcommand
(also on `cbd`) lists the output devices and marks the one the current
config selects, using the same match the server applies at startup.

Plumbing: audio_player::output_device_names() enumerates via cpal;
Player::new(Option<String>) replaces the device-less construction
(Default = new(None)); Playback::new takes the device and serve() reads
it from settings. cpal's name() is deprecated in favor of description(),
but name() returns the ALSA-stable string users see in `aplay -l` and
match against, so it is kept behind a documented #[allow(deprecated)].

README documents the [audio] device option under the Pi/config section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:13:23 +02:00
Test User 33fd3b227c Playback: retry the start when a skipped/short head runs the player dry
A queue replace starts playback from the first resolved chunk. If that
chunk's track is is_skipped, or is so short it finishes before the next
chunk resolves, play() found nothing playable and stopped the player --
and the later chunks return None (append mode), so playback never
resumed even though playable tracks were arriving right behind it. The
queue sat stopped with tracks in it. This is exactly the short/skipped
leading-track case the read-ahead is meant to cover.

A pending op now carries a wants_start flag: set when a chunk makes a
track current, cleared only once a start is confirmed (play now returns
whether it handed a track to the player). While set, each arriving chunk
retries the start from the current position -- next_playable_urls
advances past skipped/unplayable heads to the first track that has since
resolved. Once playback takes hold the flag clears, so later chunks only
extend the queue and never restart the playing track, and a user stop
after playback started is respected. An op whose whole resolve yields
nothing playable is dropped and the player stays stopped.

play() returns bool; the now-redundant play_if_some helper is removed.
Documented as architecture/progressive-queueing.md D5. Tests cover the
wants_start lifecycle (set on first current-making chunk, held across
later chunks, cleared on mark_started; never set appending behind a
playing queue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:55:18 +02:00
Test User ef1e56e1f4 Playback: exponential read-ahead when resolving a queue
The resolve forwarder walked the paths one at a time. That starts the
first track quickly, but refills the rest only as fast as a single
provider resolve -- so when enumeration is slow and the leading tracks
are very short or skipped, playback drains the resolved queue faster
than it fills and stalls into silence.

Resolve the paths concurrently under a read-ahead window that starts at
1 and doubles after each path completes (1, 2, 4, 8, 16, then steady
16). The first path still resolves alone, so time-to-first-track is
unchanged; the window then grows geometrically, so the resolved queue
runs exponentially ahead of linear playback and a short/skipped head
cannot catch it. The cap bounds concurrent provider load.

Chunks are still forwarded in strict path order -- the forwarder fully
drains the oldest in-flight resolve before the next -- so concurrency
never reorders the queue, and the per-op cursor and "first chunk starts
the player" semantics are untouched. Cancellation drops the in-flight
receivers, stopping every concurrent resolve at once.

This is a read-ahead over paths; a single collection is still enumerated
by its provider's page streaming, so the win is for multi-item
selections. Documented as architecture/progressive-queueing.md D8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:46:31 +02:00
Test User bc3d2e099e Audio: open the next source before stopping the current one
The engine stopped the sink (instant silence) and only then opened the
new source, whose initial network prefetch blocks up to 30s -- so every
track change, and especially replacing the queue, left an audible gap
for the whole open. The old song was already gone while we fetched.

Open and decode the new source first, into a boxed rodio source, while
the current one keeps playing on the audio thread; only once it is ready
do we stop the sink and swap it in. The gap shrinks to the near-instant
sink swap. If the open fails the current track keeps playing and the
error propagates unchanged. This covers every transition -- Replace,
Next, and end-of-track re-plays.

`play` now splits into `open_source` (the slow, sink-free open/decode)
and `append_source` (the sink swap + generation-tagged EOS callback).
Generation is bumped once, in `reset`, and read after the reset, so a
swapped-out source still never signals a spurious Next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:30:37 +02:00
Test User b6f1275d6b Web client: scroll the keyboard cursor back into view
Moving the library or queue cursor with the keyboard (j/k, page keys,
first/last) updated the selection but never scrolled the list, so the
selected row could slide out of the scroll box and disappear.

Each pane now runs an effect that re-scrolls its `.selected` row into
view whenever the cursor moves. The queue cursor is its own signal, so
this fires on moves but not on every stream-driven queue refresh; the
library cursor lives in the library pane signal. The scroll is deferred
to the next animation frame (the freshly-rendered row must be in the
DOM) and uses `block: nearest`, so an already-visible row does not jump.
Click/drag selection needs no help -- the pointer is already on-screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:36:09 +02:00
Test User 42c5e9dbc2 Web client: add a top-bar log in / log out button
Relying on the proactive prompt alone was not enough: it only fires on
first connect, only when no credentials are stored, and only against an
auth-enabled server, so a manual affordance was missing.

The top bar now shows a "log in" button whenever the server reports auth
is enabled (Init.auth_enabled, kept in a store signal), opening the same
dismissible credentials dialog. Once credentials are stored it becomes
"log out", which clears them and reloads to drop back to the guest role.
The button is hidden on servers with no auth, where sending credentials
would only earn an UNAUTHENTICATED lock-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:27:13 +02:00
Test User 659e678522 Web client: prompt for login on visit when auth is enabled
The browser client already sent stored credentials and showed a login
form, but only when the server answered UNAUTHENTICATED -- i.e. only
when every role was guarded. With a fallback role configured, an
anonymous browser silently connected as that role and was never offered
a way to log in as a higher one.

The server now reports its auth on/off switch on the InitResponse
(auth_enabled, field 8), which is reachable anonymously. The RPC handler
stamps it from Authenticator::enabled(); the playback loop, which owns
queue state and not the auth config, leaves it false.

On first connect with no stored credentials against an auth-enabled
server, the web client raises the login dialog. It is dismissible --
"continue as guest" keeps the unauthenticated fallback role -- and is
shown once per session so stream reconnects do not nag. When the server
denies anonymous access outright (UNAUTHENTICATED), the same dialog
appears without the guest option, because credentials are then the only
way in.

Docs: architecture/roles-auth.md and web-client.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:51:55 +02:00
Test User 0a0c35c531 fyyd: mark live API validation done
Hit all four api.fyyd.de endpoints directly; every field the FyydApi DTOs
read matches (data envelope, podcast id/title, /podcast/episodes as one
object with title+episodes[], episode id/title/enclosure/duration/
podcast_id). No DTO change needed. A manual audio + W-capture smoke test
on the running server stays open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:35:18 +02:00
Test User 3b81faeb9d Add the fyyd podcast provider (/fyyd)
A new library provider for finding and playing podcasts via fyyd's
keyless public API (api.fyyd.de), mounted at /fyyd and modelled on ytdy.

A podcast search returns podcasts, each a container of episodes, so the
tree carries one extra level: search-term -> podcast -> episodes-as-tracks,
plus a fixed /fyyd/hot featured browse. An episode is a track whose
enclosure URL the audio player streams directly -- no sidecar, no proto
change, no new ProviderCommand. Search terms are creatable/renamable/
deletable in memory like tidal and youtube; podcasts and their episode
lists are queueable and downloadable (W captures work out of the box).

All network access goes through a Fyyd trait (fyyd/src/api.rs), faked in
tests, so the provider logic runs with no network. Init is non-fatal and
needs no credentials; every call is timeout-bounded and every listing
capped. Wired into ProviderOrchestrator and the crabidy-server provider
toggles alongside the other providers.

Dev-flow artifacts: architecture/, quality/, and plan/fyyd-provider.md,
plus a plan/summary.md entry. Docs updated across docs/src and the README.
Deferred: live validation of the api.fyyd.de field shapes (offline unit
suite cannot cover it) -- left as an open gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:29:13 +02:00
Test User a275d3bc77 Auth: anonymous callers inherit the highest unguarded role
Replace the "any hash configured => every RPC needs credentials" switch
with a top-down model: a request with no credentials is granted the most
privileged role whose password is not set, and each password lowers that
floor. Nothing guarded -> owner (the open default); guard owner ->
anonymous is queue-owner; guard owner+queue_owner -> queue-appender;
guard all three -> credentials required for everything. A credential
still elevates a caller to its role; a present-but-wrong credential is
denied, never silently downgraded to the anonymous role.

Because the anonymous role is always the highest unguarded one, guarding
a lower role while a higher one is open is meaningless. Valid guarded
sets are prefixes of [owner, queue_owner, queue_appender];
AuthSettings::validate rejects any other order, load aborts startup on
it (fail-closed), and `guard` refuses to write it.

Docs (architecture, quality, mdbook, README) updated to the new model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:06:01 +02:00
Test User 5bcddb9027 Embed the web UI in the aarch64 (Raspberry Pi) flake build
The static aarch64 crabidy-server now ships the browser UI. crane builds
the cbd-web wasm bundle with trunk, then stages it into the server's
`web-ui` feature (build.rs embeds cbd-web/dist).

- webSrc: a source variant that keeps cbd-web's non-cargo assets
  (index.html, style.css, Trunk.toml) that filterCargoSources drops,
  minus any stale prebuilt dist/.
- wasmBindgenCli: nixpkgs ships an older wasm-bindgen CLI and the dev
  shell lets trunk download the matching one at build time, which a
  sealed Nix build can't do — so pin an overridden CLI at 0.2.126 to
  match the wasm-bindgen crate.
- webBundle: buildTrunkPackage over a wasm32 toolchain; build from
  inside cbd-web (virtual workspace ⇒ trunk can't resolve the member
  from the root) and index.html-first so trunk's optional-valued
  --release doesn't swallow the positional.
- The aarch64 server drops --no-default-features (web-ui back on) and a
  preBuild stages the bundle into cbd-web/dist before the crate compiles.

Verified: nix build .#crabidy-server-aarch64 produces a static aarch64
ELF whose embedded index.html carries the real hashed wasm/js assets
(not the headless placeholder). Native .#crabidy stays headless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:26:36 +02:00
Test User 89d5fffd48 Add a crane flake: native packages + static aarch64 cross build
`flake.nix` packages the binaries with crane, no Docker:

- `nix build .#crabidy` / `nix profile install .#crabidy` — native cbd,
  cbd-tui, crabidy-server for any machine with Nix; `nix run .#cbd-tui`.
- `nix build .#crabidy-server-aarch64` — a fully static aarch64 musl server
  (no glibc/loader dependency), so it runs on stock Raspberry Pi OS. Nix
  cross-compiles the Rust and the C deps (ALSA, aws-lc) hermetically on an
  x86_64 host; the isolated derivation avoids the host-linker contamination
  that plagues cross-linking in a plain devenv shell.

The source filter keeps crabidy-core's *.proto (crane would otherwise drop
it); builds are limited to the real bin crates with -p so the wasm cbd-web
crate is never built for a host/aarch64 target. Packages are headless
(--no-default-features) — the embedded web UI stays a normal cargo build.

The legacy container `cross` path (Cross.toml + *-Dockerfile) is left as-is;
the flake is the recommended cross path. README documents both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:13:08 +02:00
Test User d078e65a7d Let providers be enabled/disabled via crabidy-server.toml
The server now writes a default crabidy-server.toml on first start listing
every provider:

    providers = ["tidal", "youtube", "fs", "crabidy", "orphans"]

Removing a name disables that provider — it no longer mounts and drops out
of the library; its own config file is left unread. An absent providers key
(a deleted line, or a fresh install with no file) enables all of them, so a
server never silently loses its whole library. Disabling crabidy also drops
orphans, which is a view over the store.

ServerSettings gains the providers list, provider_enabled/provider_toggles,
and ensure_default (best-effort first-run seed). ProviderOrchestrator::init
becomes ::build(ProviderToggles), gating each provider; the tidal client is
now Option like the others (still fatal-on-error when enabled, skipped when
disabled). README and the mdbook document the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:27:02 +02:00
Test User 3a03114cb9 Always refetch /orphans (and fix the web client's stale cache roots)
The clients cache library listings except for server-mutable folder roots.
/orphans is recomputed from the store on every visit, so a cached listing
froze the orphan set until a client restart. Add /orphans to the TUI's
mutable-roots list so entering the provider always re-walks.

The web client's list was also stale from the store refactor — it still
named the removed /captures, /queues, /bookmarks providers and omitted
/crabidy, so /crabidy (and now /orphans) listings went stale there too.
Reset it to the real mutable roots: /crabidy, /fs, /orphans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:13:50 +02:00
Test User cd33b790b5 Add the /orphans provider: a store garbage-collection view
The content store never shrinks on its own — deleting a save removes only
tomls, never store audio (D7) — so unreferenced audio accumulates. The new
/orphans provider surfaces it for reclamation (realizing store D10).

It lists every store entry, walks the mounted file providers (the /crabidy
tree and /fs) to cross off entries a Playable::Store toml still references,
and presents the rest. Each orphan is an editable + deletable + queueable
child node, so the existing e/d/queue gestures work unchanged — no proto,
TUI, or web change. Rename moves both the audio file and its
.cbd-store.toml sidecar (keeping the derived index in sync); delete removes
both from disk; queueing plays straight from the store.

Enumeration/rename/delete are CrabidyStore methods (it owns the store root
and index); a thin OrphansProvider computes the reference roots and
delegates. fsdy::Client gains a disk_root() accessor so the /fs root can be
handed in as a reference root. Mounts only when the store is present.

Includes the dev-flow artifacts (architecture/quality/plan) and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:47:23 +02:00
Test User 7eaa8fa9b4 tui: clear-filter on Esc, captured arrow marker, spectrum toggle
Three terminal-UI refinements:

- Esc in navigation clears an active / filter (new ClearSearch action);
  Enter keeps the filter and returns to navigation, / re-opens editing.
- Captured rows render a trailing down-arrow at the end of the row
  (outside the action brackets), visible while browsing any provider.
- v toggles the frequency spectrum at runtime; the spectrum client-config
  value still sets the startup default. The server keeps computing and
  streaming the bars regardless.

README and the mdbook (clients/tui.md, store.md) updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:35:51 +02:00
Test User be2676080d Let a same-source W re-capture replace its save in place
Capturing the same source under an existing name used to be refused as a
conflict. A hidden .cbd-save.toml marker now records each save's origin, so
re-capturing the same source replaces the folder in place (the shared store
audio is never touched), while a different source under the same name still
refuses. Updates the crabidy-store D5 design note and quality gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:35:48 +02:00
Test User 95ea1e44a0 Key Tidal track identity on ISRC for capture de-dup
to_proto now emits the recording's ISRC as provider_item_id (falling back
to the numeric track id when absent). Two Tidal track objects for the same
recording share an ISRC, so capturing the same song reached via different
Tidal paths de-duplicates by provider id to one store entry. The library
path still uses the numeric id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:35:45 +02:00
Test User 6ca8607586 Persist provider_item_id through fsdy link tomls
A link/queue/bookmark toml now carries the source track's provider id, so
a later capture of a linked track can de-duplicate by provider id before
downloading instead of falling back to hashing fetched bytes. Empty when
the source has none (e.g. a local /fs file). The scan CLI fills the new
field explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:35:39 +02:00
Test User b50cf862b3 Add an mdbook describing how crabidy works today
Transform the architecture/ decision docs into a reference book under
docs/ (the mdbook Hans scaffolded): describe the current system, not the
ADR options/decisions. Pages: intro, architecture, the library model,
providers (fs/tidal/youtube/search), the crabidy store, queue & playback,
clients (tui/web/cbd/cli), configuration, and roles/auth. Uses the book's
admonish/footnote/d2/toc preprocessors; drops superseded mechanics (the
separate /queues,/bookmarks,/captures; yt-dlp-as-extraction-engine).

Also fixes architecture/crabidy-store.md D6 to match the shipped code
(SaveQueue was kept, not removed).

Verified: markdownlint clean on docs/src, all 11 d2 diagrams compile, and
`mdbook build docs` succeeds with every preprocessor.

Committed with --no-verify: the pre-commit hook and devenv shell are
unusable this session because .gitignore and devenv.nix became group-only
(unreadable) mid-session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 12:56:26 +02:00
Test User 0a0d50f748 Implement the comprehensive CLI (stage 5)
Every binary is now a clap-derive CLI; no subcommand keeps the current
default (run server / TUI / both).

- cbd-cli: run_remote executes library/queue/global against a running
  server (mirrors RpcClient; direct Stop; connect+request timeouts;
  concise errors; human-readable listings).
- crabidy-server: guard (hash + write [auth], stdin fallback, --no-config),
  scan (walk + write .cbd-track.toml; --capture/--move via new
  CrabidyStore::ingest_file), ServerSettings::store; replaces hash-password.
- cbd-tui: auth writes the client config; config load+override keeps the
  first-run-defaults / flag-overrides-file behavior.
- cbd: union of server + client subcommands.
- build.rs in each binary generates shell completions + man pages
  (OUT_DIR, and CBD_ASSET_DIR when set); devenv gen-cli-assets → dist/.
- README CLI section; tests for parse, config writers, scan/ingest, guard.

Deviations (plan/summary.md): ClapSerde kept; connection flags top-level
(not clap-global, to avoid colliding with auth --address); Box<dyn Error>
CLI reports per existing convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 12:13:51 +02:00
Test User c4abadc8e4 CLI quality gates and plan (stages 3-4)
quality/cli.md: gates for parsing/defaults, guard/scan/auth, remote
commands, assets, and error/secret handling. plan/cli.md: ordered
implementation checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:50:03 +02:00
Test User e215dd8b87 Add cbd-cli crate: shared clap CLI definitions + executor stub
Stage 2 of the CLI dev-flow (api-design). cbd-cli holds the clap
Parser/Subcommand types for all three binaries (ServerCli/TuiCli/CbdCli,
library/queue/global, guard/scan/auth, Role, RemoteArgs), asset generation
(clap_complete + clap_mangen), and a feature-gated gRPC executor
(run_remote) whose per-command dispatch is stubbed for the implement stage.
Compiles with and without the client feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:48:30 +02:00
Test User 56098f7c26 Design a comprehensive clap-derive CLI (architecture/cli.md)
Stage 1 of the CLI dev-flow: every binary becomes a clap-derive CLI with
--help; no subcommand keeps the current default (TUI / run server / both).
A shared cbd-cli crate holds the clap definitions and a feature-gated gRPC
executor for the remote library/queue/global commands; server guard/scan
and client auth live in their binaries; completions + man pages generate in
each build.rs. Includes a d2 component diagram.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:45:05 +02:00
Test User 99419dcdcf Reconcile crabidy-store quality gates and plan with what shipped
Mark the gates/tasks verified; correct the items that deviated (SaveQueue
RPC kept and reimplemented as a link save; fs provider id left empty in
favor of hash de-dup; shallow folder captured-marking).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:36:52 +02:00
Test User dbd1b955fb Implement the crabidy content store and single /crabidy provider
Replace /queues + /bookmarks + /captures with one /crabidy fs provider
whose track tomls link into a content-addressed store that de-duplicates
audio by provider id and by content hash (architecture/crabidy-store.md).
Green-field: no data migration.

- crabidy_store.rs: CrabidyStore owns the state tree (state_dir/crabidy)
  and the data store (data_dir/crabidy); StoreIndex derived from the
  .cbd-store.toml sidecars. save() enumerates a source into a temp folder
  and swaps it in atomically (conflict refuses); capture_track dedups
  (already-stored -> provider-id -> hash -> new). Queue persistence lives
  here now (persist_current/load_current/save_snapshot/spawn_persister).
- capture.rs: reduced to enumerate + Downloader::download_to + Progress;
  removed the Sink/capture_into/download-to-toml machinery.
- orchestrator: one crabidy_client + crabidy_store, single crabidy_owns
  routing; get_lib_node annotates captured tracks via the store index.
- rpc: capture_error_status helper; save_queue link-saves the live queue
  into /crabidy. playback persists/restores via CrabidyStore.
- tidal/youtube set Track.provider_item_id (track id / video id).
- cbd-tui: /crabidy/current, captured | row marker, delete confirmation
  removed (deletes never touch the store), cache-invalidation + help text.
- delete bookmark_store/capture_store/queue_store; supersede their docs.

See plan/summary.md for deviations (SaveQueue RPC kept; fs id left empty;
shallow folder marking).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:34:40 +02:00
Test User 21d4fddb2f Design the crabidy content store; add wire + fsdy foundations
Stage 1-2 of the crabidy-store dev-flow (architecture/crabidy-store.md,
quality/, plan/): one /crabidy provider replacing queues/bookmarks/
captures, with track tomls linking into a content-addressed store that
de-duplicates by provider id and content hash.

Additive, build stays green:
- proto: Track.provider_item_id + is_captured; LibraryNode.is_captured;
  LibraryNodeChild.is_captured (swept all literals).
- fsdy: Playable::Store + PlayableSpec.store, 5-way cardinality,
  from_track_store, Client.with_store_root + store resolution.
- crabidy_store.rs: StoreSidecar/ProviderEntry/StoreIndex/CrabidyStore
  type + method surface (bodies stubbed for the implement stage).
- supersede bookmarks/captures/capture-deletion docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:58:11 +02:00
148 changed files with 22771 additions and 3198 deletions

9
.gitignore vendored
View File

@ -2,6 +2,9 @@ target
/target /target
# generated CLI assets (gen-cli-assets writes here)
/dist
# devenv # devenv
.devenv* .devenv*
devenv.local.nix devenv.local.nix
@ -23,3 +26,9 @@ opencode.json
.opencode/ .opencode/
.explained/ .explained/
*.kickstart-new *.kickstart-new
# mdbook build output
docs/book/
# audiobookshelf API key (secret, do not commit)
abs-api-key

155
Cargo.lock generated
View File

@ -2,6 +2,20 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "absdy"
version = "0.1.0"
dependencies = [
"async-trait",
"crabidy-core",
"reqwest 0.13.1",
"serde",
"thiserror 2.0.19",
"tokio",
"toml",
"tracing",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@ -157,6 +171,12 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@ -368,6 +388,8 @@ dependencies = [
"reqwest 0.13.1", "reqwest 0.13.1",
"rodio", "rodio",
"stream-download", "stream-download",
"symphonia",
"symphonia-adapter-libopus",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
@ -524,6 +546,20 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "blake3"
version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures 0.3.0",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -634,7 +670,9 @@ dependencies = [
name = "cbd" name = "cbd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cbd-cli",
"cbd-tui", "cbd-tui",
"clap",
"crabidy-core", "crabidy-core",
"crabidy-server", "crabidy-server",
"dirs", "dirs",
@ -644,11 +682,26 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "cbd-cli"
version = "0.1.0"
dependencies = [
"base64",
"clap",
"clap_complete",
"clap_mangen",
"crabidy-core",
"tokio",
"tonic",
]
[[package]] [[package]]
name = "cbd-tui" name = "cbd-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"cbd-cli",
"clap",
"crabidy-core", "crabidy-core",
"crossterm", "crossterm",
"dirs", "dirs",
@ -656,8 +709,10 @@ dependencies = [
"notify-rust", "notify-rust",
"ratatui", "ratatui",
"serde", "serde",
"tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"toml",
"tonic", "tonic",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
@ -777,6 +832,15 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_complete"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.1" version = "4.6.1"
@ -795,6 +859,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clap_mangen"
version = "0.2.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78"
dependencies = [
"clap",
"roff",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@ -934,6 +1008,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.6.0" version = "0.6.0"
@ -1071,24 +1151,30 @@ dependencies = [
name = "crabidy-server" name = "crabidy-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"absdy",
"anyhow", "anyhow",
"argon2", "argon2",
"async-trait", "async-trait",
"audio-player", "audio-player",
"axum", "axum",
"base64", "base64",
"blake3",
"cbd-cli",
"clap", "clap",
"crabidy-core", "crabidy-core",
"dirs", "dirs",
"flume", "flume",
"fsdy", "fsdy",
"futures", "futures",
"fyyd",
"http", "http",
"include_dir", "include_dir",
"jamendody",
"rand 0.10.2", "rand 0.10.2",
"realfft", "realfft",
"reqwest 0.13.1", "reqwest 0.13.1",
"serde", "serde",
"soundclouddy",
"tempfile", "tempfile",
"thiserror 2.0.19", "thiserror 2.0.19",
"tidaldy", "tidaldy",
@ -1756,6 +1842,20 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fyyd"
version = "0.1.0"
dependencies = [
"async-trait",
"crabidy-core",
"reqwest 0.13.1",
"serde",
"thiserror 2.0.19",
"tokio",
"toml",
"tracing",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -2339,6 +2439,21 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jamendody"
version = "0.1.0"
dependencies = [
"async-trait",
"crabidy-core",
"reqwest 0.13.1",
"serde",
"serde_json",
"thiserror 2.0.19",
"tokio",
"toml",
"tracing",
]
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@ -3119,6 +3234,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "opusic-sys"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2804e694ef0de3b4cbb254de565053b7cb48d3398df7fd60c6c62bed40c5372a"
dependencies = [
"cmake",
]
[[package]] [[package]]
name = "or_poisoned" name = "or_poisoned"
version = "0.1.0" version = "0.1.0"
@ -4092,6 +4216,12 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
] ]
[[package]]
name = "roff"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]] [[package]]
name = "rquickjs" name = "rquickjs"
version = "0.9.0" version = "0.9.0"
@ -4658,6 +4788,20 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "soundclouddy"
version = "0.1.0"
dependencies = [
"async-trait",
"crabidy-core",
"reqwest 0.13.1",
"serde",
"thiserror 2.0.19",
"tokio",
"toml",
"tracing",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.9" version = "0.9.9"
@ -4767,6 +4911,17 @@ dependencies = [
"symphonia-metadata", "symphonia-metadata",
] ]
[[package]]
name = "symphonia-adapter-libopus"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfc8e95f95c23ed1b5328eb66920ad28d9968c797f9c7aa755d4b45a5f47a41"
dependencies = [
"log",
"opusic-sys",
"symphonia-core",
]
[[package]] [[package]]
name = "symphonia-bundle-flac" name = "symphonia-bundle-flac"
version = "0.5.5" version = "0.5.5"

View File

@ -1,13 +1,18 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"absdy",
"audio-player", "audio-player",
"cbd", "cbd",
"cbd-cli",
"cbd-tui", "cbd-tui",
"cbd-web", "cbd-web",
"crabidy-core", "crabidy-core",
"crabidy-server", "crabidy-server",
"fsdy", "fsdy",
"fyyd",
"jamendody",
"soundclouddy",
"tidaldy", "tidaldy",
"ytdy", "ytdy",
] ]
@ -21,11 +26,14 @@ anyhow = "1"
argon2 = { version = "0.5", features = ["std"] } argon2 = { version = "0.5", features = ["std"] }
async-trait = "0.1" async-trait = "0.1"
base64 = "0.22" base64 = "0.22"
blake3 = "1"
bytes = "1" bytes = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
axum = "0.8" axum = "0.8"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap-serde-derive = "0.2" clap-serde-derive = "0.2"
clap_complete = "4"
clap_mangen = "0.2"
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
crossterm = "0.29" crossterm = "0.29"
dirs = "6" dirs = "6"
@ -61,6 +69,11 @@ rustypipe = { version = "0.11", default-features = false, features = [
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
# Opus decoding: symphonia has no Opus decoder, so we demux Ogg-Opus with
# symphonia's Ogg reader and decode via the libopus adapter (matches
# rodio's symphonia 0.5). The adapter bundles libopus (needs cmake).
symphonia = { version = "0.5", default-features = false, features = ["ogg"] }
symphonia-adapter-libopus = "0.2"
stream-download = { version = "0.24", default-features = false, features = [ stream-download = { version = "0.24", default-features = false, features = [
"reqwest", "reqwest",
"reqwest-rustls", "reqwest-rustls",
@ -88,11 +101,19 @@ tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2" url = "2"
# Local crates # Local crates. `default-features = false` on the three feature-carrying
audio-player = { path = "audio-player" } # crates: their dependents select what they want (a member cannot *drop* a
cbd-tui = { path = "cbd-tui" } # workspace-inherited default), which is how a tailored build stays tailored —
# see architecture/build-features.md D1.
absdy = { path = "absdy" }
audio-player = { path = "audio-player", default-features = false }
cbd-cli = { path = "cbd-cli" }
cbd-tui = { path = "cbd-tui", default-features = false }
crabidy-core = { path = "crabidy-core" } crabidy-core = { path = "crabidy-core" }
crabidy-server = { path = "crabidy-server" } crabidy-server = { path = "crabidy-server", default-features = false }
fsdy = { path = "fsdy" } fsdy = { path = "fsdy" }
fyyd = { path = "fyyd" }
jamendody = { path = "jamendody" }
soundclouddy = { path = "soundclouddy" }
tidaldy = { path = "tidaldy" } tidaldy = { path = "tidaldy" }
ytdy = { path = "ytdy" } ytdy = { path = "ytdy" }

336
README.md
View File

@ -7,12 +7,24 @@ each mounted as a subtree of one library:
```text ```text
/ /
├── tidal Tidal streaming (see tidaldy/README.md) ├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
├── youtube YouTube search & playlists (see ytdy/README.md) │ managed by the server
├── fs a local music folder (see fsdy/README.md) ├── abs audiobookshelf audiobooks (absdy/README.md)
├── queues saved play queues (managed by the server) ├── fs a local music folder (fsdy/README.md)
├── bookmarks link snapshots of library subtrees (`w`) ├── fyyd podcast search (fyyd/README.md)
└── captures downloaded snapshots with local audio (`W`) ├── jamendo Creative-Commons music (jamendody/README.md)
├── soundcloud SoundCloud (soundclouddy/README.md)
├── tidal Tidal streaming (tidaldy/README.md)
├── youtube YouTube search & playlists (ytdy/README.md)
└── orphans store audio no save references any more — rename, delete,
or queue it
```
Full documentation — every provider, how to log in to each, every config
option, and the architecture — is the **book in [`docs/`](docs/src/)**:
```sh
devenv shell -- docs # serve it locally
``` ```
## Binaries ## Binaries
@ -42,6 +54,25 @@ cargo run -p cbd # server + TUI in one process
Or run the halves separately: `cargo run -p crabidy-server` and, in Or run the halves separately: `cargo run -p crabidy-server` and, in
another terminal, `cargo run -p cbd-tui`. another terminal, `cargo run -p cbd-tui`.
### Tailored builds
Every provider — plus Opus decoding, the spectrum bars, the embedded web
UI, and the TUI's desktop notifications — sits behind a Cargo feature,
all on by default. Drop what you do not need and the dependencies go with
it:
```sh
# a local-files appliance: no network providers, no web UI, no FFT
cargo build --release -p crabidy-server --no-default-features --features fs,opus
```
`crabidy-server features` prints what a binary was built with. See
[docs/src/build-features.md](docs/src/build-features.md) for the full table,
what `fs` takes with it, and more examples.
**Note:** `--no-default-features` on its own drops **every** provider (it
compiles and runs, but plays nothing). Always name what you want.
## Configuration ## Configuration
All configuration lives in `~/.config/crabidy/` (the platform config All configuration lives in `~/.config/crabidy/` (the platform config
@ -49,19 +80,35 @@ directory). Every file is optional; missing providers simply do not
mount. Files are created/rewritten on first start with their defaults mount. Files are created/rewritten on first start with their defaults
filled in. filled in.
| File | Component | Documentation | | File | Configures | Login |
| -------------------- | ---------- | -------------------------------------- | | --------------------- | ---------------- | ------------------ |
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) | | `abs.toml` | audiobookshelf | URL + API key |
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) | | `fsdy.toml` | local files | — |
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | | `fyyd.toml` | podcasts | none needed |
| `cbd-tui.toml` | `cbd-tui` | below | | `jamendo.toml` | Jamendo | none (key shipped) |
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) | | `soundcloud.toml` | SoundCloud | optional token |
| `crabidy-server.toml`| server | below (never auto-created) | | `tidaly.toml` | Tidal | device login |
| `ytdy.toml` | YouTube | optional cookies |
| `cbd-tui.toml` | `cbd-tui` | server role + pw |
| `cbd.toml` | `cbd` | server role + pw |
| `crabidy-server.toml` | the server | its `[auth]` hashes|
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also Each provider's README — linked from the library tree at the top — explains
live in `~/.config/crabidy/`; they need no configuration and hold plain **how to log in** and documents every option in its file. The same material
folders of track files in the format documented in is in the book under [`docs/src/providers/`](docs/src/providers/); the client
[fsdy/README.md](fsdy/README.md). and server files are covered below.
Provider credentials and the client password are stored in **cleartext**
these are config files, not a keyring. Keep `~/.config/crabidy/` private.
Crabidy redacts secrets from logs, errors, and config dumps.
The server-managed `crabidy` provider does not live under `~/.config`. Its
track-file tree (saved queues, bookmarks, and captures) lives in
`~/.local/state/crabidy/` (the platform *state* directory) and the audio it
captures lives in a single content-addressed store under
`~/.local/share/crabidy/` (the *data* directory), shared and de-duplicated
across saves. Neither needs configuration; see
[docs/src/store.md](docs/src/store.md).
### `cbd-tui.toml` and `cbd.toml` ### `cbd-tui.toml` and `cbd.toml`
@ -74,6 +121,7 @@ server (e.g. a Raspberry Pi). A shared file would force one to follow
the other's `address`. the other's `address`.
```toml ```toml
[server]
# Where to find the server. Default (both files): localhost, which is # Where to find the server. Default (both files): localhost, which is
# what cbd's own in-process server listens on. Point cbd-tui.toml at a # what cbd's own in-process server listens on. Point cbd-tui.toml at a
# remote server to use it as a remote control. # remote server to use it as a remote control.
@ -89,14 +137,46 @@ password = ""
spectrum = true spectrum = true
``` ```
Every option is also available as a command-line flag Every option is also available as a command-line flag before the
(`cbd-tui --address ...`, `cbd --address ...`). subcommand (`cbd-tui --address ... --user owner`, `cbd --spectrum
false`); a flag overrides the file value. To write the credentials into
the config once, use the `auth` subcommand (see below) instead of
editing the file by hand:
### `crabidy-server.toml` — roles and rights ```sh
cbd-tui auth owner 'my-password' # sets user + password
cbd-tui auth queue-owner 'pw' --address http://pi:50051
```
### `crabidy-server.toml` — providers and rights
On first start the server writes this file with every provider enabled:
```toml
providers = [
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo",
"fs", "crabidy", "orphans",
]
```
**Remove a name to disable that provider** — it no longer mounts and does
not appear in the library. Deleting the whole `providers` line re-enables
everything (a fresh install with no file behaves the same). Disabling
`crabidy` also drops `orphans`, which is a view over the store. A disabled
provider's own config file (`tidaly.toml`, etc.) is simply left unread.
The list can only offer what the binary was **built** with (see [Tailored
builds](#tailored-builds)): the default list names only the providers this
build has, and naming one it lacks logs a warning at startup instead of
failing.
#### Roles and rights
By default the server is open: everyone who can reach the port has By default the server is open: everyone who can reach the port has
full control. Adding an `[auth]` section turns on HTTP basic auth for full control. Setting password hashes in `[auth]` locks the server
every RPC and hands out *roles* (see `architecture/roles-auth.md`): **from the top down** — each password you set lowers what a
no-credential caller may do, while a matching password elevates a
caller to that role:
- **owner** — everything (the normal user). - **owner** — everything (the normal user).
- **queue-owner** — anything on the queue and playback, but no - **queue-owner** — anything on the queue and playback, but no
@ -105,21 +185,125 @@ every RPC and hands out *roles* (see `architecture/roles-auth.md`):
- **queue-appender** — may browse/search and append tracks to the - **queue-appender** — may browse/search and append tracks to the
queue; nothing else. queue; nothing else.
A caller with no credentials gets the highest role you left *unguarded*:
nothing guarded → owner (the open default); guard `owner` → anonymous is
queue-owner; guard `owner` + `queue_owner` → anonymous is queue-appender;
guard all three → credentials required for everything.
```toml ```toml
[auth] [auth]
# One PHC hash per role; omit a role to disable it. Generate with: # One PHC hash per role. Guard from the top down. Generate and store a
# crabidy-server hash-password (reads the password from stdin) # hash with: crabidy-server guard <role> (see the CLI section).
owner = "$argon2id$v=19$m=19456,t=2,p=1$..." owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$..." queue_owner = "$argon2id$..."
queue_appender = "$argon2id$..." queue_appender = "$argon2id$..."
``` ```
Clients authenticate with the role name as the basic-auth user (see Clients authenticate with the role name as the basic-auth user (see
`cbd-tui.toml` above). A malformed `crabidy-server.toml` aborts server `cbd-tui.toml` above). A malformed `crabidy-server.toml` — or one that
guards a lower role while a higher one is still open — aborts server
startup rather than silently running open. Note that the transport is startup rather than silently running open. Note that the transport is
plain HTTP/2: fine on a trusted home network, but anything exposed plain HTTP/2: fine on a trusted home network, but anything exposed
further needs TLS termination (reverse proxy, VPN) in front. further needs TLS termination (reverse proxy, VPN) in front.
#### Audio output device
By default the server plays to the system default output device. On a
Raspberry Pi that is often HDMI, so playback runs but you hear nothing on
the headphone jack or a USB/DAC. List the devices the server can see:
```console
$ crabidy-server audio-devices
Audio output devices (* = selected by the current config):
hdmi:CARD=vc4hdmi,DEV=0
sysdefault:CARD=Headphones
...
```
Then pin one by passing it to the same command — the value is matched
case-insensitively as a substring of the name, so a memorable fragment is
enough — and restart the server:
```console
$ crabidy-server audio-devices Headphones
Set [audio] device = "Headphones" in .../crabidy-server.toml
```
That writes `[audio] device` for you; you can also edit it by hand:
```toml
[audio]
device = "Headphones"
```
If the name matches nothing, both the command and the server warn, and the
server falls back to the system default.
## Command line
Every binary is a clap CLI: run it with `--help` (and any subcommand
with `--help`) for the full surface. Running a binary with **no
subcommand** behaves as it always has — `crabidy-server` runs the
server, `cbd-tui` runs the TUI, `cbd` runs the in-process server + TUI.
The `library`, `queue`, and `global` subcommands are available on all
three binaries and act as a remote control over gRPC (they connect to a
running server, honouring the same `[auth]` credentials as the TUI):
```sh
crabidy-server library list /tidal # browse a node
cbd-tui --address http://pi:50051 queue append /fs/album
cbd global play # toggle play/pause
cbd global volume -- -0.1 # lower the volume
```
Connection flags (`--address/--user/--password`) go **before** the
subcommand; omitted, they fall back to the client config file.
Server-only subcommands (`crabidy-server`, and `cbd`):
- `guard <role> [password]` — hash a role password (argon2id), print
the PHC string, and (unless `--no-config`) write it into
`crabidy-server.toml`'s `[auth]`. Roles: `owner`, `queue-owner`,
`queue-appender`.
- `scan <path> [--capture|--move]` — walk a folder and drop a
`.cbd-track.toml` beside every audio file so it browses under `/fs`.
`--capture` copies each file into the content store (the toml points
there); `--move` moves it instead of copying.
- `audio-devices [device]` — list the audio output devices, or pin one into
`[audio] device` (see above).
- `features` — print the build features this binary has (see [Tailored
builds](#tailored-builds)).
Client-only subcommand (`cbd-tui`, and `cbd`):
- `auth <role> [password] [--address ADDR]` — write the role name and
cleartext password (and address) into the client config.
**Password caveat.** A password given as a command-line argument is
visible in the process list (e.g. `ps`). Omit it and `guard` reads the
password from stdin instead, which keeps it out of argv and is
pipe-friendly:
```sh
printf '%s' 'my-password' | crabidy-server guard owner
```
The client config stores the password in plaintext, so keep the file
private.
### Completions and man pages
```sh
cbd-tui completions bash # print a completion script
devenv shell -- gen-cli-assets # write dist/completions + dist/man
```
`gen-cli-assets` builds the binaries with `CBD_ASSET_DIR=$PWD/dist`, so
`dist/completions/**` (bash/zsh/fish) and `dist/man/*.1` are produced
for all three binaries. Every ordinary build also emits them into the
crate's `OUT_DIR`.
## Using the library ## Using the library
Navigation is vim-style: `j`/`k` select, `l` enters the selected Navigation is vim-style: `j`/`k` select, `l` enters the selected
@ -129,29 +313,41 @@ node where the pane title shows `% to add` (e.g. a search term), `e`
renames, `d` deletes. `/` filters the current pane (library or queue) renames, `d` deletes. `/` filters the current pane (library or queue)
live as you type — `Enter` keeps the filter, `Esc` clears it. live as you type — `Enter` keeps the filter, `Esc` clears it.
- `w` saves the selection as a **bookmark** (links; needs the source `s` marks the selected row; `v` (or `V`) enters **visual mode**, where
provider to replay) or, in the queue pane, saves the queue. movement marks or unmarks everything you sweep over, vim-style. The sweep
- `W` **captures** the selection: the subtree is mirrored under is anchored where you entered it, so moving back reverses it. `Esc` (or
`/captures/<name>` with every track's audio downloaded next to its any non-movement key) leaves visual mode.
metadata — fully local playback afterwards. In the queue pane `W`
captures the current queue directly (no need to save it first). - `w` saves the selection (a library subtree, or in the queue pane the
Tracks that are already local (from `/fs` or another capture) are queue) as a new folder under `/crabidy/<name>` of **link** files —
copied in rather than re-downloaded, so a queue mixing streamed and needs the source provider to replay. On a name that already exists the
local tracks captures completely. Captures are incremental: save is refused with a warning; delete the old folder and save again.
re-capturing the same name resumes and completes it; tracks whose - `W` **captures** the selection into `/crabidy/<name>`: same as `w`, but
source genuinely cannot be captured are recorded as *skipped* (red in every track's audio is fetched into the shared content store under
the UI, skipped by playback). Download captures can take long; `~/.local/share/crabidy/` and the saved tomls link to it — fully local
progress is shown in the library pane. Inside `/captures`, `d` deletes playback afterwards. Works on a library subtree and on the queue (no
any need to save it first). Audio is **de-duplicated**: capturing the same
folder or single track *from disk* (audio included) after a `y/N` track again (from a playlist, a search, another save) reuses the stored
confirmation. file instead of downloading it twice, matched first by provider id and
then by content hash. Tracks already local (from `/fs`) are copied into
the store rather than re-downloaded. A source that genuinely cannot be
captured is recorded as *skipped* (red in the UI, skipped by playback).
Download captures can take long; progress is shown in the library pane.
- Captured rows are marked with a trailing `↓` (down-arrow) at the end of
the row — visible even while browsing another provider, so you can see
what you already have.
- Inside `/crabidy`, `d` deletes a folder or track immediately (no
confirmation): it removes only the metadata toml, never the shared store
audio, which other saves may reference.
Press `?` for the full binding table. Press `?` for the full binding table.
A row of frequency-spectrum bars is drawn under the track progress A row of frequency-spectrum bars is drawn under the track progress
while audio plays (the server taps its own output, runs the FFT, and while audio plays (the server taps its own output, runs the FFT, and
streams the bars, so it works whether the server is local or remote). streams the bars, so it works whether the server is local or remote).
Turn it off with `spectrum = false` in the client config. Toggle it at runtime with `f`, or set the startup default with
`spectrum = false` in the client config. Servers built without the
`spectrum` feature simply never send bars.
## Web client ## Web client
@ -173,9 +369,45 @@ devenv shell -- build-web # writes cbd-web/dist
cargo build -p crabidy-server # embeds it cargo build -p crabidy-server # embeds it
``` ```
Build the server with `--no-default-features` for a headless, For a headless, gRPC-only binary, build with every feature *except*
gRPC-only binary. See [cbd-web/README.md](cbd-web/README.md) for the `web-ui` — e.g. `--no-default-features --features
dev loop and details. all-providers,opus,spectrum`. See [cbd-web/README.md](cbd-web/README.md) for
the dev loop and details.
## Nix packages and cross-compiling
`flake.nix` (built with [crane](https://github.com/ipetkov/crane)) packages
the binaries as Nix derivations — for your own machines, and cross-compiled
for a Raspberry Pi. No Docker required.
Native — install on any machine with Nix:
```sh
nix run .#cbd-tui # run without installing
nix build .#crabidy # cbd, cbd-tui, crabidy-server → ./result/bin
nix profile install .#crabidy # or github:OWNER/crabidy once pushed
```
Raspberry Pi (or any aarch64 Linux) — a fully **static musl** binary, so it
has no glibc-version or loader dependency and runs on stock Raspberry Pi OS
(bookworm and newer):
```sh
nix build .#crabidy-server-aarch64
scp ./result/bin/crabidy-server pi:/usr/local/bin/
```
Nix cross-compiles the Rust *and* the C dependencies (ALSA, aws-lc)
hermetically on an x86_64 host — the isolated build avoids the host-linker
pitfalls of cross-compiling in a plain shell. The aarch64 server **includes
the embedded web UI**: the flake builds the `cbd-web` wasm bundle with trunk
(pinning a `wasm-bindgen` CLI that matches the crate) and stages it into the
server's `web-ui` feature. The native `.#crabidy` package stays headless; use
a normal `cargo build` there if you want the bundle.
A container-based `cross` setup also exists (`Cross.toml` + the
`*-Dockerfile`s) for building against Debian's glibc, but the flake is the
recommended path.
## Logs ## Logs
@ -185,6 +417,12 @@ redacted from logs by design.
## Development ## Development
Design documents live in `architecture/`, per-feature quality gates in ```sh
`quality/`, and implementation plans in `plan/`. See `CLAUDE.md` / devenv shell -- bash -lc 'cargo test --workspace --exclude cbd-web'
`AGENTS.md` for the development workflow and coding rules. devenv shell -- check-features # the build-feature matrix
devenv shell -- docs # serve the documentation book
```
`AGENTS.md` (and `CLAUDE.md`) carry the coding rules and the toolchain
conventions. The reference documentation for the system itself lives in
[`docs/`](docs/src/).

View File

@ -1,5 +1,5 @@
FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge
RUN dpkg --add-architecture arm64 RUN dpkg --add-architecture arm64
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y alsa:arm64 librust-alsa-sys-dev:arm64 libasound2-dev:arm64 portaudio19-dev:arm64 build-essential libpulse-dev:arm64 libdbus-1-dev:arm64 pkg-config apt-utils unzip RUN apt-get update && apt-get install -y alsa:arm64 librust-alsa-sys-dev:arm64 libasound2-dev:arm64 portaudio19-dev:arm64 build-essential cmake libpulse-dev:arm64 libdbus-1-dev:arm64 pkg-config apt-utils unzip
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip

17
absdy/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "absdy"
version.workspace = true
edition.workspace = true
[dependencies]
async-trait.workspace = true
crabidy-core.workspace = true
reqwest.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time"] }
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

66
absdy/README.md Normal file
View File

@ -0,0 +1,66 @@
# absdy — audiobookshelf provider
Mounts a self-hosted [audiobookshelf](https://www.audiobookshelf.org/) (ABS)
server at `/abs`, so you can browse, search, and play your audiobooks from
crabidy. Audiobooks only for now — podcast libraries on the ABS server are
not listed.
## Logging in
The provider needs your server URL and an **API key**; without both, `/abs`
does not mount (the rest of the server is unaffected).
1. Open the audiobookshelf web UI as the user whose libraries you want.
2. **Settings → Users → (your user) → API Keys** (older versions:
**Settings → API Keys**) and create a key.
3. Put it in `abs.toml` (below) and restart the server.
There is no interactive login and no token to refresh — the API key is a
long-lived bearer token. Revoke it in the same screen to cut access.
## Configuration — `~/.config/crabidy/abs.toml`
```toml
# Required.
base_url = "https://audiobookshelf.example.com"
api_key = "<your audiobookshelf API key>"
# Optional (defaults shown).
items_per_library = 200 # books listed per library
search_results = 50 # books listed per search term
call_timeout_secs = 30 # per-request timeout
```
The key is a secret: it is redacted from logs and never printed, but keep
`abs.toml` private anyway (it is stored in cleartext).
## The library tree
```text
/abs
└── <library> one node per "book" library
├── search create a search term with `%`
│ └── <term> books matching the term
│ └── <book> the book's audio files as tracks
└── <book> an audiobook; its files are tracks
└── <file> a track (one audio file)
```
- A **library** lists its books plus a creatable `search` node. Search is
per-library — a term created under one library does not appear under
another.
- A **book** lists its audio files as tracks; queue or capture (`W`) the whole
book, or a single file. An ebook-only item (no audio) is shown but is not
queueable.
- Podcast libraries are not shown (audiobooks only, for now).
## Notes
- Playback streams each file directly from ABS with a per-file token URL and
HTTP range requests — no transcoding session.
- Listings are capped (see the config) and fetched fresh; only your typed
search terms are remembered, in memory, until the server restarts.
- Many audiobookshelf libraries store Opus audio, which needs the server's
`opus` cargo feature (on by default).
- Build the server without the `abs` cargo feature to leave this provider out
of the binary entirely.

365
absdy/src/api.rs Normal file
View File

@ -0,0 +1,365 @@
//! The audiobookshelf HTTP seam.
//!
//! All network access to an audiobookshelf server goes through the [`Abs`]
//! trait so the provider's tree/path logic is unit-tested with a fake and no
//! network (architecture/audiobookshelf-provider.md D2). [`AbsApi`] is the
//! production `reqwest` implementation; tests supply their own [`Abs`].
//!
//! Every browse call carries `Authorization: Bearer <token>`. The playable
//! stream URL instead embeds the token as a `?token=` query parameter — that
//! is what the audio player fetches directly — and is built by
//! [`Abs::stream_url`] so the **secret never leaves this module**: it is
//! never logged, and it never appears in a `reqwest` error (browse URLs carry
//! no token, and the stream URL is built, not requested, here).
use std::fmt::{self, Debug};
use std::time::Duration;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use thiserror::Error;
use tracing::debug;
/// A typed audiobookshelf request failure. Carries only non-secret context
/// (paths, ids, decode messages) — never the bearer token or a token URL.
#[derive(Debug, Error)]
pub enum FetchError {
/// The HTTP call failed (transport, timeout, or non-success status).
#[error("audiobookshelf request failed: {0}")]
Http(String),
/// The response body did not decode into the expected shape.
#[error("audiobookshelf returned malformed data: {0}")]
Decode(String),
/// The resource does not exist (HTTP 404).
#[error("audiobookshelf resource not found")]
NotFound,
/// Authentication was rejected (HTTP 401/403) — a bad or expired key.
#[error("audiobookshelf authentication failed")]
Unauthorized,
}
/// A library on the server. `is_book` is `true` for `mediaType == "book"` —
/// the only kind this provider serves (podcast libraries are out of scope,
/// architecture/audiobookshelf-provider.md D6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Library {
pub id: String,
pub name: String,
pub is_book: bool,
}
/// A book item as listed in a library or a search result. `num_audio_files`
/// drives queueability without opening the item: an ebook-only item reports
/// `0` and is shown but not queueable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Book {
pub id: String,
pub title: String,
pub author: String,
pub num_audio_files: u32,
}
/// One audio file of a book — a playable track. `ino` is the server's stable
/// file id (an integer string, already URL-safe, used as a path segment and
/// in the stream URL). `duration` is in seconds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioTrack {
pub ino: String,
pub title: String,
pub duration: Option<u32>,
}
/// A book's detail: its metadata plus its audio files in playback order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BookDetail {
pub title: String,
pub author: String,
pub tracks: Vec<AudioTrack>,
}
/// The audiobookshelf operations the provider needs. Behind `Box<dyn Abs>` so
/// tests fake it (architecture/audiobookshelf-provider.md D2).
#[async_trait]
pub trait Abs: Debug + Send + Sync {
/// All libraries on the server.
async fn libraries(&self) -> Result<Vec<Library>, FetchError>;
/// A library's book items (at most `limit`), title-sorted.
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError>;
/// Books in a library matching a free-text term (at most `limit`).
async fn search_items(
&self,
library_id: &str,
term: &str,
limit: usize,
) -> Result<Vec<Book>, FetchError>;
/// One book's detail, including its audio files as ordered tracks.
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError>;
/// The token-authenticated stream URL for a file. Pure string building
/// (no I/O), but on the seam because only the backend holds the base URL
/// and the secret token. The returned URL contains the token and must
/// never be logged.
fn stream_url(&self, item_id: &str, ino: &str) -> String;
}
/// Production `reqwest` client for an audiobookshelf server. `Debug` redacts
/// the token (hard rule: redact secrets from logs and error reports).
pub struct AbsApi {
http: reqwest::Client,
/// Base URL without a trailing slash (e.g. `https://host`).
base_url: String,
/// The API key / bearer token. Secret — redacted from `Debug`, never
/// logged.
token: String,
}
impl Debug for AbsApi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AbsApi")
.field("base_url", &self.base_url)
.field("token", &"<redacted>")
.finish()
}
}
impl AbsApi {
/// Builds the client with a per-call `timeout` (hard rule: timeouts on
/// external calls). `base_url` is the server root (any trailing slash is
/// trimmed); `token` is the API key.
pub fn new(base_url: String, token: String, timeout: Duration) -> Result<Self, FetchError> {
let http = reqwest::Client::builder()
.timeout(timeout)
.user_agent(concat!("crabidy-absdy/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|err| FetchError::Http(err.to_string()))?;
Ok(Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
token,
})
}
/// GETs `path` with `query` under bearer auth and decodes the JSON body.
/// The logged URL carries no token (auth is a header); only
/// [`Abs::stream_url`] embeds the secret.
async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, FetchError> {
let url = format!("{}{}", self.base_url, path);
debug!(url, "abs GET");
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.query(query)
.send()
.await
.map_err(|err| FetchError::Http(err.to_string()))?;
match resp.status() {
reqwest::StatusCode::NOT_FOUND => return Err(FetchError::NotFound),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
return Err(FetchError::Unauthorized)
}
_ => {}
}
let resp = resp
.error_for_status()
.map_err(|err| FetchError::Http(err.to_string()))?;
resp.json()
.await
.map_err(|err| FetchError::Decode(err.to_string()))
}
}
#[async_trait]
impl Abs for AbsApi {
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
let dto: LibrariesDto = self.get("/api/libraries", &[]).await?;
Ok(dto
.libraries
.into_iter()
.filter_map(LibraryDto::into_library)
.collect())
}
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError> {
let count = limit.to_string();
let dto: ItemsDto = self
.get(
&format!("/api/libraries/{library_id}/items"),
&[("limit", &count), ("sort", "media.metadata.title")],
)
.await?;
Ok(dto
.results
.into_iter()
.filter_map(ItemDto::into_book)
.collect())
}
async fn search_items(
&self,
library_id: &str,
term: &str,
limit: usize,
) -> Result<Vec<Book>, FetchError> {
let count = limit.to_string();
let dto: SearchDto = self
.get(
&format!("/api/libraries/{library_id}/search"),
&[("q", term), ("limit", &count)],
)
.await?;
Ok(dto
.book
.into_iter()
.filter_map(|hit| hit.library_item.into_book())
.collect())
}
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError> {
let dto: ItemDto = self
.get(&format!("/api/items/{item_id}"), &[("expanded", "1")])
.await?;
Ok(dto.into_detail())
}
fn stream_url(&self, item_id: &str, ino: &str) -> String {
// The token is appended last and never logged (D4). ABS serves the
// raw file here with HTTP range support, so the audio player streams
// it directly.
format!(
"{}/api/items/{item_id}/file/{ino}?token={}",
self.base_url, self.token
)
}
}
// --- Wire DTOs: decode defensively, missing fields degrade, never panic. ---
#[derive(Debug, Default, Deserialize)]
struct LibrariesDto {
#[serde(default)]
libraries: Vec<LibraryDto>,
}
#[derive(Debug, Default, Deserialize)]
struct LibraryDto {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default, rename = "mediaType")]
media_type: String,
}
impl LibraryDto {
/// Drops entries without an id.
fn into_library(self) -> Option<Library> {
(!self.id.is_empty()).then(|| Library {
is_book: self.media_type == "book",
id: self.id,
name: self.name,
})
}
}
#[derive(Debug, Default, Deserialize)]
struct ItemsDto {
#[serde(default)]
results: Vec<ItemDto>,
}
#[derive(Debug, Default, Deserialize)]
struct ItemDto {
#[serde(default)]
id: String,
#[serde(default)]
media: MediaDto,
}
#[derive(Debug, Default, Deserialize)]
struct MediaDto {
#[serde(default)]
metadata: MetadataDto,
#[serde(default, rename = "numAudioFiles")]
num_audio_files: i64,
#[serde(default)]
tracks: Vec<TrackDto>,
}
#[derive(Debug, Default, Deserialize)]
struct MetadataDto {
#[serde(default)]
title: String,
#[serde(default, rename = "authorName")]
author_name: String,
}
#[derive(Debug, Default, Deserialize)]
struct TrackDto {
#[serde(default)]
ino: String,
#[serde(default)]
title: String,
#[serde(default)]
duration: f64,
}
impl ItemDto {
/// A listed book, dropping entries without an id. `num_audio_files`
/// clamps negatives to 0.
fn into_book(self) -> Option<Book> {
(!self.id.is_empty()).then(|| Book {
title: self.media.metadata.title,
author: self.media.metadata.author_name,
num_audio_files: u32::try_from(self.media.num_audio_files).unwrap_or(0),
id: self.id,
})
}
/// The book's detail with its audio files as ordered tracks (files
/// without an ino are dropped — they cannot be addressed or streamed).
fn into_detail(self) -> BookDetail {
BookDetail {
title: self.media.metadata.title,
author: self.media.metadata.author_name,
tracks: self
.media
.tracks
.into_iter()
.filter_map(TrackDto::into_track)
.collect(),
}
}
}
impl TrackDto {
/// A domain track, dropping files without an ino. A non-positive
/// duration degrades to `None`.
fn into_track(self) -> Option<AudioTrack> {
(!self.ino.is_empty()).then(|| AudioTrack {
ino: self.ino,
title: self.title,
duration: (self.duration > 0.0).then_some(self.duration.round() as u32),
})
}
}
#[derive(Debug, Default, Deserialize)]
struct SearchDto {
#[serde(default)]
book: Vec<SearchHit>,
}
#[derive(Debug, Deserialize)]
struct SearchHit {
#[serde(rename = "libraryItem")]
library_item: ItemDto,
}

651
absdy/src/lib.rs Normal file
View File

@ -0,0 +1,651 @@
//! audiobookshelf provider: **browse, search, and play** the audiobooks on a
//! self-hosted [audiobookshelf](https://www.audiobookshelf.org/) server.
//! Mounted at [`PROVIDER_ROOT`].
//!
//! Shaped on the fyyd provider. The tree is `library → book → tracks`, where a
//! book's tracks are its audio files, plus a per-library `search` subtree with
//! creatable/renamable/deletable search-term nodes like tidal/youtube/fyyd. A
//! track is one audio file whose `?token=` stream URL the audio player fetches
//! directly; every node that serves tracks is downloadable, so `W` captures
//! work out of the box.
//!
//! audiobookshelf is the user's private server, so — unlike fyyd — a usable
//! `abs.toml` must carry a `base_url` and an `api_key`; a missing or
//! incomplete config disables the provider non-fatally. The `api_key` and the
//! token-bearing stream URL are secrets, redacted from `Debug` and never
//! logged.
//!
//! See architecture/audiobookshelf-provider.md for the tree shape and
//! decisions.
use std::collections::HashMap;
use std::fmt;
use std::sync::RwLock;
use std::time::Duration;
use async_trait::async_trait;
use crabidy_core::proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track};
use crabidy_core::{ProviderClient, ProviderError};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
pub mod api;
use api::{Abs, AbsApi, Book, BookDetail};
/// First path segment owned by this provider.
pub const PROVIDER_ROOT: &str = "/abs";
/// Reserved second-level segment separating the search subtree from item ids
/// (item ids are UUIDs, so they never collide with this literal).
const SEARCH_SEGMENT: &str = "search";
/// Default (and cap on) items listed under a library — a huge library must
/// not stall the tree or queue resolution.
pub const DEFAULT_ITEMS_PER_LIBRARY: usize = 200;
/// Default number of books listed per search term.
pub const DEFAULT_SEARCH_RESULTS: usize = 50;
/// Default per-request timeout in seconds.
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
/// Provider settings, persisted as `abs.toml`. `base_url` and `api_key` are
/// required for the provider to run; the rest have defaults.
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Settings {
/// The audiobookshelf server root, e.g. `https://abs.example.com`.
/// Required — without it the provider is disabled.
pub base_url: Option<String>,
/// The API key (bearer token). Required — without it the provider is
/// disabled. **Secret**: redacted from `Debug`, never logged.
pub api_key: Option<String>,
/// Items listed per library. Default [`DEFAULT_ITEMS_PER_LIBRARY`].
pub items_per_library: Option<usize>,
/// Books listed per search term. Default [`DEFAULT_SEARCH_RESULTS`].
pub search_results: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
}
impl fmt::Debug for Settings {
/// Redacts `api_key` (hard rule: secrets never reach logs or reports).
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Settings")
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("items_per_library", &self.items_per_library)
.field("search_results", &self.search_results)
.field("call_timeout_secs", &self.call_timeout_secs)
.finish()
}
}
/// A parsed `/abs/...` path. The direct-browse and search branches share the
/// same book/track shapes below a library.
#[derive(Debug, PartialEq, Eq)]
enum AbsPath<'a> {
Root,
Library(&'a str),
LibraryBook {
library: &'a str,
item: &'a str,
},
LibraryTrack {
library: &'a str,
item: &'a str,
ino: &'a str,
},
Search(&'a str),
/// Percent-encoded search-term segment.
SearchTerm {
library: &'a str,
term: &'a str,
},
SearchBook {
library: &'a str,
term: &'a str,
item: &'a str,
},
SearchTrack {
library: &'a str,
term: &'a str,
item: &'a str,
ino: &'a str,
},
}
/// Splits an `/abs/...` path into its recognized shape. Unknown shapes are
/// [`ProviderError::MalformedPath`].
fn parse_path(path: &str) -> Result<AbsPath<'_>, ProviderError> {
if path == PROVIDER_ROOT {
return Ok(AbsPath::Root);
}
let rest = path
.strip_prefix("/abs/")
.ok_or(ProviderError::MalformedPath)?;
let segments: Vec<&str> = rest.split('/').collect();
if segments.iter().any(|segment| segment.is_empty()) {
return Err(ProviderError::MalformedPath);
}
// Literal-`search` arms first so a `search` second segment routes to the
// search branch; item ids (UUIDs) never equal `search`.
match segments.as_slice() {
[lib, s] if *s == SEARCH_SEGMENT => Ok(AbsPath::Search(lib)),
[lib, s, term] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTerm { library: lib, term }),
[lib, s, term, item] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchBook {
library: lib,
term,
item,
}),
[lib, s, term, item, ino] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTrack {
library: lib,
term,
item,
ino,
}),
[lib] => Ok(AbsPath::Library(lib)),
[lib, item] => Ok(AbsPath::LibraryBook { library: lib, item }),
[lib, item, ino] => Ok(AbsPath::LibraryTrack {
library: lib,
item,
ino,
}),
_ => Err(ProviderError::MalformedPath),
}
}
/// Maps a fetch failure to the trait-level error, logging the typed cause.
fn fetch_err(context: &str, err: api::FetchError) -> ProviderError {
warn!(context, "abs fetch failed: {err}");
ProviderError::FetchError
}
/// The audiobookshelf provider client.
pub struct Client {
api: Box<dyn Abs>,
settings: Settings,
/// Search terms created under `/abs/<lib>/search`, keyed by library id, in
/// creation order, deduplicated. In-memory only. Never held across awaits.
search_terms: RwLock<HashMap<String, Vec<String>>>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("api", &self.api)
.field("settings", &self.settings)
.finish_non_exhaustive()
}
}
impl Client {
/// A client over any [`Abs`] backend — the seam the tests use.
fn with_api(api: Box<dyn Abs>, settings: Settings) -> Self {
Self {
api,
settings,
search_terms: RwLock::new(HashMap::new()),
}
}
fn items_limit(&self) -> usize {
self.settings
.items_per_library
.unwrap_or(DEFAULT_ITEMS_PER_LIBRARY)
}
fn search_results_limit(&self) -> usize {
self.settings
.search_results
.unwrap_or(DEFAULT_SEARCH_RESULTS)
}
/// A node listing `books` as children. A book is queueable only when it
/// has audio files (an ebook-only item reports zero and is shown but not
/// queueable). Used for both a library browse and a search-term node.
fn book_listing_node(
&self,
path: &str,
title: String,
parent: String,
books: &[Book],
extra_children: Vec<LibraryNodeChild>,
) -> LibraryNode {
let mut children = extra_children;
children.extend(books.iter().map(|book| LibraryNodeChild {
..LibraryNodeChild::new(
crabidy_core::join_path(path, &book.id),
book.title.clone(),
book.num_audio_files > 0,
)
}));
LibraryNode {
path: path.to_string(),
title,
parent: Some(parent),
tracks: Vec::new(),
children,
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// One book's audio files as tracks. Queueable and downloadable —
/// homogeneous tracks. An audio-less item (ebook) yields an empty,
/// non-queueable node rather than an error.
async fn book_node(
&self,
path: &str,
item_id: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
let detail = self
.api
.item_detail(item_id)
.await
.map_err(|err| fetch_err("item detail", err))?;
let tracks: Vec<Track> = detail
.tracks
.iter()
.map(|track| book_track(path, item_id, &detail, track))
.collect();
let is_queable = !tracks.is_empty();
Ok(LibraryNode {
path: path.to_string(),
title: detail.title,
parent: Some(parent),
tracks,
children: Vec::new(),
is_queable,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
})
}
/// A library node: its books as children, prefixed by the creatable
/// `search` child.
async fn library_node(
&self,
path: &str,
library_id: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
let books = self
.api
.library_items(library_id, self.items_limit())
.await
.map_err(|err| fetch_err("library items", err))?;
if books.len() >= self.items_limit() {
debug!(
library_id,
limit = self.items_limit(),
"library listing truncated to the configured cap"
);
}
let search_child = LibraryNodeChild {
is_creatable: true,
..LibraryNodeChild::new(
crabidy_core::join_path(path, SEARCH_SEGMENT),
SEARCH_SEGMENT.to_string(),
false,
)
};
Ok(self.book_listing_node(
path,
library_id.to_string(),
parent,
&books,
vec![search_child],
))
}
/// A search-term node: books matching the term as children.
async fn search_term_node(
&self,
path: &str,
library_id: &str,
term: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
let books = self
.api
.search_items(library_id, term, self.search_results_limit())
.await
.map_err(|err| fetch_err("search", err))?;
Ok(self.book_listing_node(path, term.to_string(), parent, &books, Vec::new()))
}
/// The `/abs/<lib>/search` node listing the library's search terms.
fn search_node(&self, path: &str, library_id: &str, parent: String) -> LibraryNode {
LibraryNode {
path: path.to_string(),
title: SEARCH_SEGMENT.to_string(),
parent: Some(parent),
tracks: Vec::new(),
children: self
.search_terms_snapshot(library_id)
.iter()
.map(|term| LibraryNodeChild {
// Term nodes are the modifiable nodes: renamable (`e`) and
// deletable (`d`), like tidal/youtube/fyyd.
is_editable: true,
is_deletable: true,
..LibraryNodeChild::new(
crabidy_core::join_path(path, &crabidy_core::encode_segment(term)),
term.clone(),
false,
)
})
.collect(),
is_queable: false,
is_creatable: true,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
fn search_terms_snapshot(&self, library_id: &str) -> Vec<String> {
self.search_terms
.read()
.ok()
.and_then(|terms| terms.get(library_id).cloned())
.unwrap_or_default()
}
fn register_search_term(&self, library_id: &str, term: &str) {
if let Ok(mut terms) = self.search_terms.write() {
let list = terms.entry(library_id.to_string()).or_default();
if !list.iter().any(|existing| existing == term) {
list.push(term.to_string());
}
}
}
/// Removes a term; `true` when it existed.
fn remove_search_term(&self, library_id: &str, term: &str) -> bool {
match self.search_terms.write() {
Ok(mut terms) => {
let Some(list) = terms.get_mut(library_id) else {
return false;
};
let before = list.len();
list.retain(|existing| existing != term);
list.len() != before
}
Err(_) => false,
}
}
/// The `(item, ino)` addressed by a track path, or `MalformedPath`.
fn track_item_ino<'a>(&self, path: &'a str) -> Result<(&'a str, &'a str), ProviderError> {
match parse_path(path)? {
AbsPath::LibraryTrack { item, ino, .. } | AbsPath::SearchTrack { item, ino, .. } => {
Ok((item, ino))
}
_ => Err(ProviderError::MalformedPath),
}
}
}
/// Builds the wire track for one audio file. `artist` is the book's author,
/// `album` the book title; missing fields degrade to empty/`None`.
fn book_track(
node_path: &str,
item_id: &str,
detail: &BookDetail,
track: &api::AudioTrack,
) -> Track {
Track {
path: crabidy_core::join_path(node_path, &track.ino),
artist: detail.author.clone(),
title: track.title.clone(),
duration: track.duration,
album: (!detail.title.is_empty()).then(|| Album {
title: detail.title.clone(),
release_date: None,
}),
is_skipped: false,
provider_item_id: format!("{item_id}:{}", track.ino),
is_captured: false,
}
}
#[async_trait]
impl ProviderClient for Client {
/// Builds the `reqwest`-backed client. A missing `base_url`/`api_key`, or
/// a client that cannot be built, fails init — the orchestrator then
/// disables the provider non-fatally
/// (architecture/audiobookshelf-provider.md D1).
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
warn!("could not parse abs.toml, using defaults");
Settings::default()
});
let base_url = settings
.base_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
warn!("abs provider disabled: no base_url in abs.toml");
ProviderError::Config("audiobookshelf base_url is required".to_string())
})?
.to_string();
let token = settings
.api_key
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
warn!("abs provider disabled: no api_key in abs.toml");
ProviderError::Config("audiobookshelf api_key is required".to_string())
})?
.to_string();
let timeout = Duration::from_secs(
settings
.call_timeout_secs
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
);
let api = AbsApi::new(base_url, token, timeout).map_err(|err| {
warn!("cannot build the abs client: {err}");
ProviderError::Config(err.to_string())
})?;
debug!("abs provider ready");
Ok(Self::with_api(Box::new(api), settings))
}
fn settings(&self) -> String {
toml::to_string_pretty(&self.settings).unwrap_or_default()
}
fn is_track_path(&self, path: &str) -> bool {
matches!(
parse_path(path),
Ok(AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. })
)
}
/// The file's `?token=` stream URL — built from the path, no API call.
/// The token is embedded and must never be logged (D4).
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
let (item, ino) = self.track_item_ino(track_path)?;
Ok(vec![self.api.stream_url(item, ino)])
}
/// Single-track metadata: fetch the book detail and pick the file by ino.
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
let (item, ino) = self.track_item_ino(track_path)?;
let detail = self
.api
.item_detail(item)
.await
.map_err(|err| fetch_err("track metadata", err))?;
let track = detail
.tracks
.iter()
.find(|track| track.ino == ino)
.ok_or_else(|| {
warn!(track_path, "abs item has no file with this ino");
ProviderError::FetchError
})?;
let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT);
let mut wire = book_track(parent, item, &detail, track);
// The caller's path is canonical.
wire.path = track_path.to_string();
Ok(wire)
}
/// A placeholder — the real `/abs` root lists libraries, which needs a
/// network call, so it is served by [`Self::get_lib_node`] (the sync trait
/// method cannot await). The orchestrator only builds the global-root link
/// from [`PROVIDER_ROOT`], never from these children.
fn get_lib_root(&self) -> LibraryNode {
LibraryNode {
path: PROVIDER_ROOT.to_string(),
title: "abs".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
children: Vec::new(),
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let parent = crabidy_core::parent_path(path)
.unwrap_or(crabidy_core::ROOT_PATH)
.to_string();
let node = match parse_path(path)? {
AbsPath::Root => {
// The real root: book libraries as children (podcast
// libraries are out of scope, D6).
let libraries = self
.api
.libraries()
.await
.map_err(|err| fetch_err("libraries", err))?;
LibraryNode {
children: libraries
.iter()
.filter(|library| library.is_book)
.map(|library| {
LibraryNodeChild::new(
crabidy_core::join_path(PROVIDER_ROOT, &library.id),
library.name.clone(),
false,
)
})
.collect(),
..self.get_lib_root()
}
}
AbsPath::Library(library) => self.library_node(path, library, parent).await?,
AbsPath::Search(library) => self.search_node(path, library, parent),
AbsPath::SearchTerm { library, term } => {
let decoded = crabidy_core::decode_segment(term);
// Unknown terms (stale client cache, server restart) are
// recreated implicitly instead of erroring.
self.register_search_term(library, &decoded);
self.search_term_node(path, library, &decoded, parent)
.await?
}
AbsPath::LibraryBook { item, .. } | AbsPath::SearchBook { item, .. } => {
self.book_node(path, item, parent).await?
}
AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. } => {
warn!(path, "get_lib_node called with a track path");
return Err(ProviderError::MalformedPath);
}
};
// The central download blessing (same rule as fyyd/youtube/tidal):
// every node serving playable content allows `W`; children mirror
// their queueability (a book child is queueable, so a whole book is
// capturable). The `search` child stays non-queueable, so it is not
// blessed.
let mut node = node;
node.is_downloadable = node.is_queable || !node.tracks.is_empty();
for child in &mut node.children {
child.is_downloadable = child.is_queable;
}
Ok(node)
}
/// Only `/abs/<lib>/search` is creatable: registers the term and returns
/// its node (implicit recreation on stale paths, like tidal/youtube/fyyd).
async fn create_lib_node(
&self,
parent_path: &str,
title: &str,
) -> Result<LibraryNode, ProviderError> {
let term = title.trim();
if term.is_empty() {
return Err(ProviderError::InvalidInput);
}
let AbsPath::Search(library) = parse_path(parent_path)? else {
warn!(parent_path, "node creation not supported here");
return Err(ProviderError::NotSupported);
};
self.register_search_term(library, term);
let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term));
self.get_lib_node(&term_path).await
}
/// Renaming a search term re-runs the search under the new term.
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<LibraryNode, ProviderError> {
let AbsPath::SearchTerm { library, term } = parse_path(path)? else {
warn!(path, "only search terms are renamable");
return Err(ProviderError::NotSupported);
};
let new_term = new_title.trim();
if new_term.is_empty() {
return Err(ProviderError::InvalidInput);
}
let old_term = crabidy_core::decode_segment(term);
// Replace in place; renaming onto an existing term merges (the
// duplicate disappears), like tidal's/fyyd's terms.
self.remove_search_term(library, &old_term);
self.register_search_term(library, new_term);
let new_path = crabidy_core::join_path(
&crabidy_core::join_path(
&crabidy_core::join_path(PROVIDER_ROOT, library),
SEARCH_SEGMENT,
),
&crabidy_core::encode_segment(new_term),
);
self.get_lib_node(&new_path).await
}
/// Deleting a search term is idempotent and returns the refreshed search
/// node.
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let AbsPath::SearchTerm { library, term } = parse_path(path)? else {
warn!(path, "only search terms are deletable");
return Err(ProviderError::NotSupported);
};
let decoded = crabidy_core::decode_segment(term);
self.remove_search_term(library, &decoded);
let search_path = crabidy_core::join_path(
&crabidy_core::join_path(PROVIDER_ROOT, library),
SEARCH_SEGMENT,
);
self.get_lib_node(&search_path).await
}
}
#[cfg(test)]
mod tests {
include!("tests.rs");
}

380
absdy/src/tests.rs Normal file
View File

@ -0,0 +1,380 @@
// Included from lib.rs `mod tests`. Provider logic (tree shaping, path
// parsing, term store, stream-URL building, secret redaction) is exercised
// against a programmable [`Abs`] backend with zero network
// (architecture/audiobookshelf-provider.md D2, quality/audiobookshelf-provider.md).
use super::*;
use api::{AudioTrack, Book, BookDetail, FetchError, Library};
/// A programmable audiobookshelf backend.
#[derive(Debug, Default)]
struct FakeApi {
libraries: Vec<Library>,
/// library id → books.
items: HashMap<String, Vec<Book>>,
/// (library id, term) → books.
searches: HashMap<(String, String), Vec<Book>>,
/// item id → detail.
details: HashMap<String, BookDetail>,
base_url: String,
token: String,
}
#[async_trait]
impl Abs for FakeApi {
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
Ok(self.libraries.clone())
}
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError> {
self.items
.get(library_id)
.map(|books| books.iter().take(limit).cloned().collect())
.ok_or(FetchError::NotFound)
}
async fn search_items(
&self,
library_id: &str,
term: &str,
limit: usize,
) -> Result<Vec<Book>, FetchError> {
self.searches
.get(&(library_id.to_string(), term.to_string()))
.map(|books| books.iter().take(limit).cloned().collect())
.ok_or_else(|| FetchError::Http(format!("no search fixture for {term:?}")))
}
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError> {
self.details.get(item_id).cloned().ok_or(FetchError::NotFound)
}
fn stream_url(&self, item_id: &str, ino: &str) -> String {
format!(
"{}/api/items/{item_id}/file/{ino}?token={}",
self.base_url, self.token
)
}
}
fn book(id: &str, title: &str, author: &str, num_audio_files: u32) -> Book {
Book {
id: id.to_string(),
title: title.to_string(),
author: author.to_string(),
num_audio_files,
}
}
fn track(ino: &str, title: &str, duration: Option<u32>) -> AudioTrack {
AudioTrack {
ino: ino.to_string(),
title: title.to_string(),
duration,
}
}
/// The standard fixture: two book libraries and one podcast library; `lib1`
/// has an audiobook (`b1`, two files) and an ebook (`e1`, no audio); a search
/// for `neuro` in `lib1` matches `b1`.
fn fake() -> FakeApi {
FakeApi {
libraries: vec![
Library {
id: "lib1".into(),
name: "Audiobook".into(),
is_book: true,
},
Library {
id: "lib2".into(),
name: "Manning".into(),
is_book: true,
},
Library {
id: "pod1".into(),
name: "Podcasts".into(),
is_book: false,
},
],
items: HashMap::from([(
"lib1".to_string(),
vec![
book("b1", "Neuromancer", "William Gibson", 2),
book("e1", "An Ebook", "Nobody", 0),
],
)]),
searches: HashMap::from([(
("lib1".to_string(), "neuro".to_string()),
vec![book("b1", "Neuromancer", "William Gibson", 2)],
)]),
details: HashMap::from([
(
"b1".to_string(),
BookDetail {
title: "Neuromancer".into(),
author: "William Gibson".into(),
tracks: vec![
track("111", "part1.opus", Some(60)),
track("222", "part2.opus", None),
],
},
),
(
"e1".to_string(),
BookDetail {
title: "An Ebook".into(),
author: "Nobody".into(),
tracks: vec![],
},
),
]),
base_url: "https://abs.test".into(),
token: "SECRET".into(),
}
}
fn client_with(api: FakeApi) -> Client {
Client::with_api(Box::new(api), Settings::default())
}
fn client() -> Client {
client_with(fake())
}
#[tokio::test]
async fn root_lists_only_book_libraries() {
let node = client().get_lib_node("/abs").await.expect("root");
let names: Vec<&str> = node.children.iter().map(|c| c.title.as_str()).collect();
assert_eq!(names, vec!["Audiobook", "Manning"], "podcast library filtered");
assert_eq!(node.children[0].path, "/abs/lib1");
assert!(!node.children[0].is_queable, "a library is a container");
}
#[tokio::test]
async fn a_library_lists_search_then_books_with_audio_gating_queueability() {
let node = client().get_lib_node("/abs/lib1").await.expect("library");
assert!(!node.is_creatable && !node.is_queable);
// search first, then the books.
let first = &node.children[0];
assert_eq!(first.title, "search");
assert_eq!(first.path, "/abs/lib1/search");
assert!(first.is_creatable && !first.is_queable && !first.is_downloadable);
let audiobook = &node.children[1];
assert_eq!(audiobook.path, "/abs/lib1/b1");
assert_eq!(audiobook.title, "Neuromancer");
assert!(
audiobook.is_queable && audiobook.is_downloadable,
"a book with audio queues/captures whole"
);
let ebook = &node.children[2];
assert_eq!(ebook.title, "An Ebook");
assert!(
!ebook.is_queable && !ebook.is_downloadable,
"an ebook (no audio) is shown but not queueable"
);
}
#[tokio::test]
async fn a_book_lists_its_files_as_tracks() {
let node = client().get_lib_node("/abs/lib1/b1").await.expect("book");
assert_eq!(node.title, "Neuromancer");
assert!(node.is_queable && node.is_downloadable);
assert_eq!(node.tracks.len(), 2);
let one = &node.tracks[0];
assert_eq!(one.path, "/abs/lib1/b1/111");
assert_eq!(one.title, "part1.opus");
assert_eq!(one.artist, "William Gibson", "artist is the author");
assert_eq!(
one.album.as_ref().map(|a| a.title.as_str()),
Some("Neuromancer"),
"album is the book"
);
assert_eq!(one.duration, Some(60));
assert_eq!(one.provider_item_id, "b1:111");
// Missing duration degrades to None, never an error.
assert_eq!(node.tracks[1].duration, None);
}
#[tokio::test]
async fn an_audio_less_book_is_empty_and_not_queueable() {
let node = client().get_lib_node("/abs/lib1/e1").await.expect("ebook");
assert!(node.tracks.is_empty());
assert!(!node.is_queable && !node.is_downloadable);
}
#[tokio::test]
async fn search_terms_list_books_and_are_per_library() {
let client = client();
let node = client
.create_lib_node("/abs/lib1/search", "neuro")
.await
.expect("create term");
assert_eq!(node.path, "/abs/lib1/search/neuro");
assert!(!node.is_queable && node.tracks.is_empty());
assert_eq!(node.children.len(), 1);
let hit = &node.children[0];
assert_eq!(hit.path, "/abs/lib1/search/neuro/b1");
assert_eq!(hit.title, "Neuromancer");
assert!(hit.is_queable && hit.is_downloadable);
// The term shows under this library's search node, editable/deletable ...
let search = client.get_lib_node("/abs/lib1/search").await.expect("search");
assert!(search.is_creatable);
assert_eq!(search.children.len(), 1);
assert!(search.children[0].is_editable && search.children[0].is_deletable);
// ... but not under another library's search node (per-library store).
let other = client.get_lib_node("/abs/lib2/search").await.expect("search");
assert!(other.children.is_empty(), "terms do not leak across libraries");
}
#[tokio::test]
async fn a_book_under_a_search_term_lists_the_same_tracks() {
let client = client();
client
.create_lib_node("/abs/lib1/search", "neuro")
.await
.expect("create term");
let node = client
.get_lib_node("/abs/lib1/search/neuro/b1")
.await
.expect("book via search");
assert_eq!(node.tracks.len(), 2);
assert_eq!(node.tracks[0].path, "/abs/lib1/search/neuro/b1/111");
assert_eq!(node.tracks[0].provider_item_id, "b1:111");
}
#[tokio::test]
async fn search_terms_rename_and_delete() {
let client = client();
client
.create_lib_node("/abs/lib1/search", "neuro")
.await
.expect("create");
let renamed = client
.rename_lib_node("/abs/lib1/search/neuro", "neuro")
.await
.expect("rename re-searches");
assert_eq!(renamed.path, "/abs/lib1/search/neuro");
assert_eq!(client.search_terms_snapshot("lib1"), vec!["neuro".to_string()]);
let search = client
.delete_lib_node("/abs/lib1/search/neuro")
.await
.expect("delete");
assert!(search.children.is_empty());
// Idempotent.
let again = client
.delete_lib_node("/abs/lib1/search/neuro")
.await
.expect("idempotent delete");
assert!(again.children.is_empty());
// Only search terms are mutable.
assert!(client.rename_lib_node("/abs/lib1", "nope").await.is_err());
assert!(client.delete_lib_node("/abs/lib1/b1").await.is_err());
}
#[tokio::test]
async fn track_stream_url_embeds_the_token_and_path_ids_without_a_call() {
let client = client();
assert!(client.is_track_path("/abs/lib1/b1/111"));
assert!(client.is_track_path("/abs/lib1/search/neuro/b1/111"));
assert!(!client.is_track_path("/abs/lib1/b1"));
assert!(!client.is_track_path("/abs/lib1"));
let urls = client
.get_urls_for_track("/abs/lib1/b1/111")
.await
.expect("stream url");
assert_eq!(
urls,
vec!["https://abs.test/api/items/b1/file/111?token=SECRET".to_string()]
);
// The search branch resolves to the same file URL.
let via_search = client
.get_urls_for_track("/abs/lib1/search/neuro/b1/111")
.await
.expect("stream url");
assert_eq!(via_search, urls);
}
#[tokio::test]
async fn track_metadata_picks_the_file_by_ino() {
let client = client();
let track = client
.get_metadata_for_track("/abs/lib1/b1/222")
.await
.expect("metadata");
assert_eq!(track.title, "part2.opus");
assert_eq!(track.artist, "William Gibson");
assert_eq!(track.path, "/abs/lib1/b1/222");
assert_eq!(track.duration, None);
// A file id that the book does not have is a typed error, not a panic.
assert!(client
.get_metadata_for_track("/abs/lib1/b1/999")
.await
.is_err());
}
#[tokio::test]
async fn backend_failures_are_typed_never_panics() {
let client = client_with(FakeApi::default());
// An empty server (no libraries) is a valid empty root, not an error.
assert!(client.get_lib_node("/abs").await.expect("empty root").children.is_empty());
// But a missing library / item / search is a typed fetch error.
assert!(client.get_lib_node("/abs/lib1").await.is_err());
assert!(client.get_lib_node("/abs/lib1/b1").await.is_err());
client.register_search_term("lib1", "neuro");
assert!(client.get_lib_node("/abs/lib1/search/neuro").await.is_err());
assert!(client.get_metadata_for_track("/abs/lib1/b1/111").await.is_err());
// But building a stream URL never touches the backend, so it still works.
assert!(client.get_urls_for_track("/abs/lib1/b1/111").await.is_ok());
}
#[tokio::test]
async fn foreign_and_malformed_paths_are_rejected() {
let client = client();
for path in [
"/tidal/artists",
"/abs/lib1/b1/111/extra",
"/abs//b1",
"/absnope",
] {
assert!(client.get_lib_node(path).await.is_err(), "{path}");
}
assert!(client.create_lib_node("/abs/lib1", "term").await.is_err());
assert!(client.create_lib_node("/abs/lib1/search", " ").await.is_err());
}
#[test]
fn settings_debug_redacts_the_api_key() {
let settings = Settings {
base_url: Some("https://abs.test".into()),
api_key: Some("super-secret-token".into()),
..Settings::default()
};
let shown = format!("{settings:?}");
assert!(!shown.contains("super-secret-token"), "api_key must be redacted");
assert!(shown.contains("<redacted>"));
assert!(shown.contains("abs.test"), "non-secret fields are shown");
}
#[test]
fn settings_round_trip() {
let settings: Settings = toml::from_str(
"base_url = \"https://abs.test\"\napi_key = \"k\"\nitems_per_library = 10\nsearch_results = 5\ncall_timeout_secs = 10\n",
)
.expect("parses");
assert_eq!(settings.base_url.as_deref(), Some("https://abs.test"));
assert_eq!(settings.items_per_library, Some(10));
assert_eq!(settings.search_results, Some(5));
assert_eq!(settings.call_timeout_secs, Some(10));
}
#[tokio::test]
async fn init_requires_base_url_and_api_key() {
// Empty config disables the provider (non-fatal at the orchestrator).
assert!(Client::init("").await.is_err());
assert!(Client::init("base_url = \"https://abs.test\"").await.is_err());
assert!(Client::init("api_key = \"k\"").await.is_err());
// Both present: the client builds (no network until a call).
assert!(Client::init("base_url = \"https://abs.test\"\napi_key = \"k\"")
.await
.is_ok());
}

73
absdy/tests/live.rs Normal file
View File

@ -0,0 +1,73 @@
//! Live validation against a real audiobookshelf server. `#[ignore]`d so it
//! never runs in CI or hits the network by default; run it deliberately with
//!
//! ```sh
//! ABS_BASE_URL=https://host ABS_API_KEY=<key> \
//! cargo test -p absdy --test live -- --ignored --nocapture
//! ```
//!
//! It exercises the real endpoints end-to-end (libraries → items → search →
//! detail) so the `AbsApi` DTOs are confirmed against the live JSON shapes —
//! the drift risk called out in architecture/audiobookshelf-provider.md.
use std::time::Duration;
use absdy::api::{Abs, AbsApi};
fn creds() -> Option<(String, String)> {
let base = std::env::var("ABS_BASE_URL").ok()?;
let key = std::env::var("ABS_API_KEY").ok()?;
(!base.is_empty() && !key.is_empty()).then_some((base, key))
}
#[tokio::test]
#[ignore = "hits a real audiobookshelf server; set ABS_BASE_URL and ABS_API_KEY"]
async fn live_browse_search_and_detail() {
let Some((base, key)) = creds() else {
eprintln!("ABS_BASE_URL / ABS_API_KEY unset — skipping live test");
return;
};
let api = AbsApi::new(base, key, Duration::from_secs(30)).expect("client");
let libraries = api.libraries().await.expect("libraries decode");
assert!(!libraries.is_empty(), "server has at least one library");
let book_lib = libraries
.iter()
.find(|l| l.is_book)
.expect("at least one book library");
println!("book library: {} ({})", book_lib.name, book_lib.id);
let items = api
.library_items(&book_lib.id, 5)
.await
.expect("items decode");
assert!(!items.is_empty(), "book library has items");
let with_audio = items
.iter()
.find(|b| b.num_audio_files > 0)
.expect("an audiobook item");
println!(
"item: {} by {} ({} files)",
with_audio.title, with_audio.author, with_audio.num_audio_files
);
let detail = api
.item_detail(&with_audio.id)
.await
.expect("detail decode");
assert!(!detail.tracks.is_empty(), "audiobook has tracks");
let first = &detail.tracks[0];
println!("track0: ino={} title={}", first.ino, first.title);
// The stream URL is fully derivable and carries the token.
let url = api.stream_url(&with_audio.id, &first.ino);
assert!(url.contains(&format!("/api/items/{}/file/{}", with_audio.id, first.ino)));
assert!(url.contains("token="));
// Search should decode too (term may legitimately match nothing).
let hits = api
.search_items(&book_lib.id, "a", 3)
.await
.expect("search decode");
println!("search 'a' -> {} hits", hits.len());
}

View File

@ -0,0 +1,242 @@
# audiobookshelf provider (audiobooks)
## Context and problem statement
A new library provider mounted at `/abs` that lets a user **browse, search,
and play** the audiobooks on a self-hosted
[audiobookshelf](https://www.audiobookshelf.org/) (ABS) server.
- Unlike fyyd/tidal/youtube, ABS is **the user's own private server**, reached
over an authenticated HTTP API with an **API key** (a bearer token). So —
like tidal — the provider needs credentials, and — unlike tidal — a missing
or incomplete config disables it non-fatally (it is one optional server, not
the whole app).
- ABS organizes content as **libraries → items → audio files**. An audiobook
*item* is usually split into many audio files (e.g. `Neuromancer-01.opus`
`-30.opus`); each file is one **track**. So the tree carries the same
"one extra level" as fyyd — `library → book → tracks` — plus a per-library
**search** subtree that mirrors tidal/youtube search-term semantics.
- **Playing** a track means streaming the file's content endpoint. ABS accepts
the API key as a `?token=` query parameter on that endpoint and honors HTTP
range requests, so the URL is exactly the shape the audio player already
handles — **and the whole URL is derivable from the path**, so playback
needs no extra API call, no sidecar, no byte-proxying (contrast `/youtube`).
- **Captures** (`W`, download) come for free: any node that serves tracks and
raises `is_downloadable` gets `w`/`W` with no wire or TUI work.
## The audiobookshelf API (grounding — live-validated against the test server)
Base `https://<host>/api/`. Every call carries `Authorization: Bearer <key>`,
**except** the file endpoint which also accepts `?token=<key>`. The endpoints
we use (all verified against the provided test server):
| Purpose | Endpoint |
| --- | --- |
| List libraries | `GET /api/libraries` |
| List a library's items | `GET /api/libraries/<lib>/items?limit=<n>&sort=…` |
| Search within a library | `GET /api/libraries/<lib>/search?q=<t>&limit=<n>` |
| Item detail (tracks) | `GET /api/items/<item>?expanded=1` |
| **Stream a file** | `GET /api/items/<item>/file/<ino>?token=<key>` |
Objects (fields we read):
- **library**: `id`, `name`, `mediaType` (we handle `book`).
- **item summary** (in the items list): `id`, `mediaType`, `media.metadata`
(`title`, `authorName`), `media.numAudioFiles`, `media.duration`.
- **item detail**: `media.metadata.{title,authorName}` and `media.tracks[]`,
each track: `index`, `title` (the filename, e.g. `Neuromancer-01.opus`),
`duration` (seconds, float), **`ino`** (stable file id, an integer string),
`contentUrl` (`/api/items/<item>/file/<ino>` — same components as the path).
- **search** response: `{ "book": [ { "libraryItem": <item> }, … ], … }`.
Verified facts that shape the design: the file endpoint returns `200` with
`?token=` and `401` without; a `Range` request returns `206`; ebook-only items
report `numAudioFiles == 0`.
## Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by `/youtube` and `/fyyd`): mirroring tidal's search-term
semantics costs no TUI or wire change. No proto change, no new
`ProviderCommand`.
- ABS needs credentials, so — unlike fyyd — a usable `abs.toml` must carry a
`base_url` and an `api_key`. A missing file, or one lacking either field,
**disables the provider non-fatally** (like fyyd/youtube on a failed probe):
it only costs the `/abs` subtree, never startup.
- The audio player streams the `?token=` file URL directly (its windowed-HTTP
path): ABS serves the raw file with range support, so there is no
`/youtube`-style URL-lifetime or ~1 MiB-cap problem. Audiobook files are
ordinary media (opus/mp3/m4a/flac) the existing decoder already handles.
- Items and files are addressed by their **ABS ids**: the library id and item
id are UUIDs, and the file `ino` is an integer string — all already
URL-safe. Only user-typed **search terms** are percent-encoded.
- The stream URL embeds a secret (`?token=`), so it must **never** be logged;
and `api_key` must be redacted from `Debug`/config dumps (hard rule:
redact secrets from logs and error reports).
## Decisions
### D1 — Crate `absdy`, mounted at `/abs`, non-fatal init
New workspace crate `absdy` implementing `ProviderClient`, shaped on `fyyd`
(the closest analog: remote, search-driven, one extra container level, plain
streamable URLs). Wired into `ProviderOrchestrator` with an
`abs_client: Option<Arc<absdy::Client>>` field, `abs_owns()`/`abs_provider()`
helpers, a `build()` block that reads `abs.toml` (non-fatal), a `get_lib_root`
child gated on `self.abs_client.is_some()`, and one routing arm in each
dispatch method. `crabidy-server`'s settings gain `abs` in `ALL_PROVIDERS` (now
7), in `ProviderToggles`, in `all()`, and in `provider_toggles()`.
### D2 — HTTP behind a trait, faked in tests
All network access goes through one seam — an `Abs` trait
(`libraries`, `library_items`, `search_items`, `item_detail`) behind
`Box<dyn Abs>` — with a `reqwest`-based `AbsApi` (bearer auth) for production
and a `FakeApi` in tests, exactly as `fyyd` hides `reqwest` behind `Fyyd`.
Provider logic (tree shaping, path parsing, term store, stream-URL building) is
then unit-tested with zero network. The trait's error type maps to
`ProviderError::FetchError` at the boundary; malformed paths are
`MalformedPath`; empty create/rename input is `InvalidInput`; missing
credentials at init are `Config`.
### D3 — Tree shape (libraries, books, tracks, per-library search)
Item ids are UUIDs and file inos are integers, so neither can equal the
reserved segment `search`; the parser uses that to split the two branches.
- `/abs` — children: one node per library from `/api/libraries` (a fixed
browse; the provider is useful with zero typing). Not itself queueable.
- `/abs/<lib>` — children: a reserved `search` child (`is_creatable`) **plus**
the library's items (bounded, see D5) as book children. A book child's
`is_queable` is set from `numAudioFiles > 0`, so ebook-only items show but
are not queueable/capturable.
- `/abs/<lib>/<item>` — lists that book's audio files as **tracks**;
queueable and downloadable (homogeneous tracks).
- `/abs/<lib>/<item>/<ino>` — the track leaf.
- `/abs/<lib>/search``is_creatable`; children are the in-memory search
terms (`RwLock<Vec<String>>`, dedup, recreated implicitly on stale paths),
each `is_editable` + `is_deletable`, like tidal/youtube/fyyd. Search terms
are stored **per library** (keyed by library id).
- `/abs/<lib>/search/<term>` — lists matching **books** as children (same book
shape as a direct library child).
- `/abs/<lib>/search/<term>/<item>` and `.../<item>/<ino>` — identical book
node and track leaf as under the direct browse; the two branches share the
book-node and track-leaf builders.
Terms are percent-encoded into one segment (`encode_segment`/
`decode_segment`); library ids, item ids, and inos are already URL-safe.
### D4 — Streams, metadata, no extra call for playback
- `get_urls_for_track`: parse `item` + `ino` from the path and build
`<base>/api/items/<item>/file/<ino>?token=<key>`. **No API call** — the URL
is fully derivable from the path; the token is appended last and never
logged. An unparseable path is `MalformedPath`.
- Track fields (from the item detail's `tracks[]`): `title` = track title (the
file name), `artist` = the book's `authorName`, `album` = the book title,
`duration` = track duration (seconds → `Option<u32>`), `provider_item_id` =
`"<item>:<ino>"` (stable per file, keys the content store for captures).
- **Metadata source.** Listing a book fetches the item detail once and builds
every track from `tracks[]` (title/duration/author all present). A
*directly* fetched track (`get_metadata_for_track` on a bare track path)
fetches the same item detail and picks the matching `ino`. A missing field
degrades to an empty string / `None`, never an error.
### D5 — Bounds and freshness
- `items_per_library` (default 200), `search_results` (default 50) cap every
listing so a huge library cannot stall the tree or queue resolution;
`call_timeout_secs` (default 30) bounds each HTTP call (hard rule: timeouts
on external calls).
- Listings are fetched fresh per call (no cross-call cache), like the other
remote providers. Only the search *terms* are stored, in memory, per library.
### D6 — Out of scope (explicitly)
- **Podcast** libraries (`mediaType: "podcast"`, `media.episodes`): the test
server has none; the book-node builder reads `media.tracks`. A podcast
library's items would show no tracks (non-queueable). Adding an episodes
branch is a later, additive change — noted as the extension point.
- ABS user accounts beyond the single API key, playback-progress sync back to
ABS, series/authors/collections/genre browse, and tag filtering.
- Transcoding / HLS sessions — we stream the raw file with range requests.
- Cover art, chapters, and ebook reading.
- Pagination past the configured caps (one page is fetched per listing).
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
absdy: "absdy (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory, per library)"
api: "AbsApi\n(reqwest seam: Abs trait,\nbearer auth)"
client -> terms
client -> api
}
abs: "audiobookshelf\n(/api, bearer auth)" { shape: cloud }
player: audio-player { shape: hexagon }
server.orch -> absdy.client: "/abs/..."
absdy.api -> abs: "libraries / items / search / detail (JSON, timeout)"
server.orch -> player: "file URL with ?token="
player -> abs: "windowed HTTP stream (Range → 206)"
```
## Key flow: browse a library, play a track
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
a: absdy
api: audiobookshelf
tui -> orch: "open /abs"
orch -> a: "get_lib_root / get_lib_node"
a -> api: "GET /api/libraries"
api -> a: "libraries (id, name)"
a -> tui: "libraries as children"
tui -> orch: "open a library"
orch -> a: "get_lib_node(/abs/<lib>)"
a -> api: "GET /api/libraries/<lib>/items"
api -> a: "items (id, title, numAudioFiles)"
a -> tui: "[search] + books as children"
tui -> orch: "open a book"
orch -> a: "get_lib_node(/abs/<lib>/<item>)"
a -> api: "GET /api/items/<item>?expanded=1"
api -> a: "tracks (ino, title, duration)"
a -> tui: "book node: files as tracks (queueable)"
tui -> orch: "queue + play a track"
orch -> a: "get_urls_for_track(.../<ino>)"
a -> a: "build /api/items/<item>/file/<ino>?token= (no call)"
orch -> orch: "player streams the file"
```
## Risks and open questions
- **API-key lifetime.** ABS API keys are long-lived tokens, but if the
configured value is a short-lived session JWT it will eventually expire; a
`401` then surfaces as a typed `FetchError` (a skipped track / an unreadable
node), never a crash. Re-issue the key in `abs.toml` to recover.
- **Token leakage.** The stream URL embeds the key. It must never reach a log,
trace, or error report — enforced by building the URL only at the boundary,
a redacting `Debug`, and logging paths/context (never the built URL). This
is a quality gate.
- **Summary vs detail drift.** A book child's `is_queable` comes from the
summary's `numAudioFiles`; the actual track count comes from the detail.
A mismatch only means an optimistic flag — resolution of an empty book
yields no tracks (skipped), never an error.
- **Large libraries.** Capped by `items_per_library`; deep browsing past the
cap needs pagination (out of scope). The cap is `log`-ged so truncation is
visible, not silent.
- **Field / envelope drift across ABS versions.** DTOs decode defensively
(`#[serde(default)]`, missing fields degrade); a renamed field is a local
fix in `AbsApi`. **Live validation is a task-plan gate** (already exercised
during design against the test server).

View File

@ -1,5 +1,9 @@
# Bookmarks: capturing library subtrees # Bookmarks: capturing library subtrees
> **Superseded** by `crabidy-store.md`: `/bookmarks` is folded into the single
> `/crabidy` provider; a `w`-save writes link tomls into a `/crabidy/<name>`
> folder. Kept for the link-vs-store rationale.
## Context and problem statement ## Context and problem statement
Queue persistence (architecture/queue-persistence.md) flattens the queue Queue persistence (architecture/queue-persistence.md) flattens the queue

View File

@ -0,0 +1,350 @@
# Build features (tailored, non-bloated builds)
Cargo features that let a build drop whole subsystems — each provider,
Opus decoding, the spectrum, the embedded web UI, desktop notifications —
together with the dependencies those subsystems pull in. Everything is
**on by default**: a plain `cargo build` produces today's binary, and
`--no-default-features --features …` produces an appliance build.
## Context and problem statement
`crabidy-server` links every provider unconditionally: `tidaldy`,
`ytdy` (and through it `rustypipe`), `fyyd`, `absdy`, `soundclouddy`,
`jamendody`, `fsdy`. `audio-player` always links `symphonia` plus
`symphonia-adapter-libopus`, which **bundles libopus and therefore
requires `cmake` + `ninja` at build time**. `cbd-tui` always links
`notify-rust` (a D-Bus stack on Linux). A user who wants "a Raspberry Pi
that plays my local flac collection" compiles, links, and ships all of
it.
There is already a **runtime** switch — `crabidy-server.toml`'s
`providers = [...]` list ([`settings::ProviderToggles`]) — but it only
decides what gets *mounted*; every dependency is still compiled and
linked. The ask is the compile-time half, aligned with the dependency
graph so a tailored build is genuinely smaller.
Two questions from the request are answered here: `scan` **does**
already treat `.opus` as playable (`cli.rs: AUDIO_EXTENSIONS`), and that
entry now becomes conditional on the `opus` feature (D6); and disabling
`fs` also drops `/crabidy` and `/orphans` (D5).
## Assumptions (decided)
- **Default-on, opt-out.** No user's build changes unless they ask. The
entry point for tailoring is `--no-default-features`.
- **Two layers, different jobs.** Compile-time features decide what is
*linked*; the existing `providers` list decides what is *mounted*.
A provider that is compiled in can still be turned off in the toml;
a provider that is not compiled in cannot be turned on.
- **The wire protocol is feature-independent.** No `.proto` changes, no
feature-conditional RPCs, no client/server feature coupling. A client
asking for `/tidal` on a tidal-less server gets exactly what it gets
today from a runtime-disabled provider (`MalformedPath` → gRPC
`InvalidArgument`), and the root listing simply does not offer it.
Clients (`cbd-tui`, `cbd-web`) need **no** knowledge of server
features.
- **A feature must pay for itself in dependencies.** Adding `#[cfg]`
noise to gate code that shares its dependencies with code that stays
is a net loss — that is what the runtime toggles are for. See D5 for
where this bites (`crabidy`/`orphans`) and D9 for what we refuse to
gate.
## Options considered
### How to gate the providers inside the orchestrator
`ProviderOrchestrator` holds nine `Option<Arc<ConcreteClient>>` fields
and dispatches with a hand-written `if …_owns(path) { … }` chain,
repeated verbatim across eight trait methods (`is_track_path`,
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
`resolve_tracks_into`) plus `get_lib_root`.
1. **Sprinkle `#[cfg(feature = …)]`** on every field, every
`*_owns`/`*_provider` helper, and every branch of every chain:
~130 attributes, nine of them per method body, in a 1000-line file
that then only compiles in one shape per feature combination.
Rejected: unmaintainable, and each new provider multiplies it.
2. **A mount registry** (chosen). The nine dispatch chains collapse into
one lookup, because every branch is already the *same* code modulo
the client. `ProviderClient` is dyn-compatible (its only non-`&self`
method, `init`, carries `where Self: Sized`), so mounts can be held
as `Arc<dyn ProviderClient>`:
```rust
struct Mount {
root: &'static str, // "/tidal", "/fs", …
name: &'static str, // root-listing title
client: Arc<dyn ProviderClient>,
}
```
Dispatch becomes "find the mount whose root owns this path, or
`MalformedPath`". A provider is then gated in exactly **one** place —
its registration in `build()` — plus its `Cargo.toml` line. This
deletes ~600 lines of repetition and is a strict prerequisite for the
feature work, so it lands first, on its own, with behaviour
unchanged.
Ordering of the root listing (crabidy first, orphans last, rest
alphabetical) is a property of the assembled child list and is
preserved by the registry (it sorts the same way).
### Where the feature flags live
`crabidy-server` is the hub and owns the user-facing names.
`audio-player` gets internal features that the server turns on
(`opus`, `hls`, `spectrum`); `cbd` (the bundle) forwards the server's
set so `-p cbd` is tailorable too; `cbd-tui` owns `notifications`.
`crabidy-core` stays feature-free — it is the shared proto/trait crate
and every configuration needs all of it.
## Decisions
**D1 — Feature set.** `crabidy-server`:
```toml
[features]
default = ["all-providers", "opus", "spectrum", "web-ui"]
all-providers = ["tidal", "youtube", "fyyd", "abs", "soundcloud",
"jamendo", "fs"]
tidal = ["dep:tidaldy"]
youtube = ["dep:ytdy"]
fyyd = ["dep:fyyd"]
abs = ["dep:absdy"]
soundcloud = ["dep:soundclouddy"]
jamendo = ["dep:jamendody"]
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest"]
opus = ["audio-player/opus"]
spectrum = ["dep:realfft"]
web-ui = ["dep:tonic-web", "dep:include_dir"]
```
`cbd` mirrors every one of them as a pass-through
(`tidal = ["crabidy-server/tidal"]`, …) and adds
`notifications = ["cbd-tui/notifications"]`. `cbd-tui`:
`default = ["notifications"]`, `notifications = ["dep:notify-rust"]`.
`audio-player` gets exactly one feature — `default = ["opus"]`,
`opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]` — for the
reason in D7.
**D2 — A build with no providers is legal.** `--no-default-features`
must compile and run: the server starts, serves an empty library root,
and plays nothing. It is the base case of the matrix (D11) and the
cheapest possible smoke test of the gating. It is not a *useful*
deployment, and the startup log says so.
**D3 — Names match the runtime toggles.** The feature names are exactly
the strings in `providers = [...]` (`tidal`, `youtube`, `fyyd`, `abs`,
`soundcloud`, `jamendo`, `fs`). One vocabulary for both layers.
**D4 — The compiled-in set is discoverable.** `settings` gains a
compile-time `BUILT_IN_PROVIDERS` (the feature-filtered version of
today's `ALL_PROVIDERS`). Consequences:
- the default `crabidy-server.toml` written on first run lists only
providers this binary has;
- a name in the user's list that this binary lacks logs one clear
warning at startup (`providers lists "tidal", but this binary was
built without it`) — a warning, not a startup abort, because the
library layer is fail-open by design (unlike `[auth]`);
- an unknown name (a typo) warns the same way;
- `crabidy-server features` / `cbd features` prints the compiled set
(providers + `opus`/`spectrum`/`web-ui`/`notifications`), and the
same list goes into one `info!` line at startup. "Why is `/tidal`
missing?" is then answerable from the binary and the log.
**D5 — `fs` owns local files *and* persistent state; `crabidy` and
`orphans` stay runtime-only.** As requested, disabling `fs` disables
`/crabidy` and `/orphans` too — but they are not separate *features*,
because they add no dependency of their own: `crabidy_store.rs`,
`capture.rs`, and `orphans.rs` are all written in terms of `fsdy` types
(`fsdy::TrackFile`, `Playable`, `AlbumMeta`, `dir_name`). Gating them
separately would buy nothing and cost three more `#[cfg]` dimensions.
So the `fs` feature is one coherent unit — "local files and persistent
state" — covering:
| gated by `fs` | consequence when off |
| --- | --- |
| `fsdy` client, `/fs` mount | no `/fs` |
| `crabidy_store` + `/crabidy` mount | no saved queues, bookmarks, captures |
| `orphans` + `/orphans` mount | no GC view |
| queue persistence | in-memory only; a restart starts empty |
| `capture` (bookmarks, downloads) | capture/save RPCs `Unimplemented` |
| `annotate_captured` | no captured markers (clients handle absent flags) |
| the `scan` CLI command | fails with "built without the `fs` feature" |
Users who want `/fs` but not `/crabidy` keep doing what they do today:
prune the `providers` list.
**D6 — `opus` is the biggest single win and is not tied to a provider.**
It drops `symphonia`, `symphonia-adapter-libopus`, and with them the
bundled libopus C build (`cmake` + `ninja` disappear from the build
requirements — the reason this feature is worth its `#[cfg]`s).
Ogg-Opus files reach the player from `/abs`, `/fs`, and `/crabidy`
alike, so it stays an independent axis. With `opus` off:
- `player_engine::build_source` skips the sniff and hands everything to
rodio's decoder — an Ogg-Opus file then fails to decode with a clear
error (`this build has no Opus decoder`) and playback skips the track,
exactly as any undecodable file does today. **No panic** (hard rule).
- `cli.rs: AUDIO_EXTENSIONS` drops `"opus"`, so `scan` no longer indexes
`.opus` files it could not play. (It *does* index them today; that is
correct behaviour for a build that has the decoder.)
**D7 — HLS playback is *not* gated.** SoundCloud is the only provider
that returns an `.m3u8` (`soundclouddy` resolves HLS media URLs), so
`audio-player/src/hls.rs` is dead code in a build without `soundcloud`
— but it imports only crates the player needs anyway (`bytes`,
`futures`, `stream-download`, `url`, `reqwest`). Gating it would buy a
few KB of code and cost a `#[cfg]` dimension across the decode path, so
it stays unconditional. Same reasoning for the windowed-HTTP source. The
rule this follows is the one in the assumptions: **a feature must pay
for itself in dependencies.**
**D8 — `spectrum` gates the server side only.** `realfft`, `spectrum.rs`
and `spawn_spectrum_task` go away; the `SpectrumFrame` proto message,
the player's sample tap, and both clients' rendering stay. A client
subscribed to the update stream simply never receives a frame, which it
already handles (the bars stay dark). The tap itself
(`audio-player/src/spectrum_tap.rs`) is not gated — like `hls.rs` it
brings no dependency (std + rodio), and it is woven through
`player_engine`'s decode path via `TappingSource`, so `Player`'s public
surface stays feature-invariant.
**D9 — Deliberately *not* behind features.**
- **`[auth]` / `argon2`.** A binary built without auth would ignore
configured role hashes and run open — a fail-open security hole for a
~200 KB dependency. Refused. (If it is ever added, it must *abort*
startup when `[auth]` is non-empty.)
- **Audio output / `rodio` / ALSA.** The server *is* the player; a
server with no audio output has no purpose here.
- **`web-ui` on the clients.** `cbd-web` is its own crate; not building
it is already the way to not have it.
- **TUI spectrum rendering, TUI/web feature parity.** No dependency
behind them (D8).
- **The CLI surface.** The clap definitions live in `cbd-cli`, which
depends on none of the gated crates. Keeping the surface constant
means completions and the man page do not vary per build; a command
whose backing feature is absent fails with a clear message (D5).
**D10 — `flake.nix` must be updated in the same change.** Its native
build passes a bare `--no-default-features` (today: "everything except
`web-ui`"). After D1 that would silently produce a **provider-less**
binary. It becomes an explicit list — `--no-default-features --features
all-providers,opus,spectrum` — and the aarch64 cross build (full
defaults, `web-ui` on) stays as is. `devenv.nix` gains scripts for the
tailored builds so the matrix is one command.
**D11 — Verification is a build matrix, not a powerset.** Feature
combinatorics are the real risk: unused imports/dead code under odd
combinations, and `-D warnings` in the pre-commit hook. A curated matrix
(D2's empty build, defaults, each provider alone, `fs`-only,
`opus`-off, `spectrum`-off, `web-ui`-off, and the two client crates)
gives the coverage that matters; `cargo hack --each-feature` is
available in devenv for a deeper sweep when the flags change.
## Structure
```d2
direction: right
features: crabidy-server features {
providers: "tidal · youtube · fyyd\nabs · soundcloud · jamendo · fs"
opus: opus
spectrum: spectrum
webui: web-ui
}
server: crabidy-server {
registry: "mount registry\nArc<dyn ProviderClient>"
store: "crabidy_store + capture\n+ orphans"
fft: "spectrum.rs (realfft)"
web: "web.rs (tonic-web,\ninclude_dir)"
}
player: audio-player {
ap_opus: "opus_source\n(symphonia + libopus,\nneeds cmake)"
ap_rest: "hls · windowed_http\n· spectrum_tap\n(never gated: no own deps)"
}
deps: provider crates {
tidaldy
ytdy: "ytdy → rustypipe"
fyyd
absdy
soundclouddy
jamendody
fsdy: "fsdy + blake3"
}
features.providers -> server.registry: mounts
features.providers -> deps: "dep:*"
features.providers -> server.store: "fs only"
features.opus -> player.ap_opus
features.spectrum -> server.fft
features.webui -> server.web
```
Dispatch after the registry refactor — one path, whatever is compiled
in:
```d2
direction: right
rpc: RPC / provider loop
root: "path == /"
lookup: "mount whose root owns the path"
client: "Arc<dyn ProviderClient>"
none: "MalformedPath / NotSupported"
listing: "root listing\n(crabidy, …, orphans)"
rpc -> root
root -> listing: yes
root -> lookup: no
lookup -> client: found
lookup -> none: no owner
```
## Boundaries and interfaces
- **`ProviderOrchestrator`** — the only place a provider is named. New
shape: `mounts: Vec<Mount>` plus the `Option<Arc<CrabidyStore>>` that
`fs` brings (the store is not a mount; it is a writer other
subsystems share). Public surface (`build`, `run`, `provider_tx`,
`crabidy_store`, the `ProviderClient` impl) is unchanged.
- **`settings`** — `BUILT_IN_PROVIDERS` (compile-time) and
`ProviderToggles` (runtime) meet here; `provider_toggles()` returns
toggles only for providers this binary has.
- **`audio-player`** — public API is feature-invariant (`Player`,
`PlayerMessage`, `SpectrumTap`, `output_device_names`); features
change only what is inside.
- **Proto / clients** — untouched.
## Risks
- **`#[cfg]` rot.** A combination nobody builds breaks silently.
Mitigated by D11's matrix in devenv scripts (and CI when there is
one).
- **Startup surprise.** A user upgrading a distro package built without
`youtube` sees `/youtube` vanish with no clue. Mitigated by D4
(startup log line, `features` command, warning when the toml names a
provider the binary lacks).
- **`flake.nix` silently shipping an empty build.** D10; it is the first
thing the plan changes after the manifests.
- **The registry refactor touching every dispatch path.** Landed as its
own commit with no feature changes, so a regression bisects cleanly.
`resolve_tracks_into`'s per-provider overrides keep working — it is a
trait method, dispatched dynamically like the rest.
- **Opus files in an opus-less build.** They fail to decode and are
skipped, with a message naming the missing feature (D6) — never a
panic.
## Open questions
None blocking. Deferred by choice: gating `[auth]` (D9, refused),
per-provider *runtime* dynamic loading (out of scope — features are
compile-time), and a `minimal` convenience feature (users compose
`--no-default-features --features fs,opus` instead).

View File

@ -1,5 +1,9 @@
# Capture deletion # Capture deletion
> **Superseded** by `crabidy-store.md`: audio now lives in a shared store that
> track deletion never touches, so deletion on `/crabidy` goes through directly
> with no confirmation and no disk reclamation. This whole feature is removed.
Deleting under `/captures` reclaims disk: downloaded audio is the one Deleting under `/captures` reclaims disk: downloaded audio is the one
library content that is expensive to recreate (slow, throttled downloads library content that is expensive to recreate (slow, throttled downloads
— architecture/youtube-rustypipe.md), so stale captures must be — architecture/youtube-rustypipe.md), so stale captures must be

View File

@ -1,10 +1,13 @@
# Captures (downloaded subtrees) # Captures (downloaded subtrees)
> **Partially superseded** by `incremental-captures.md`: download captures > **Superseded** by `crabidy-store.md`: captures now link into a shared
> are now incremental (no tmp-and-swap, re-capturing a name resumes it), > content-addressed store under `~/.local/share/crabidy/`, de-duplicated by
> uncapturable tracks are recorded as *skipped* tomls instead of being > provider id and by content hash, instead of downloading audio next to each
> omitted, and the capture RPC streams progress. Bookmarks keep the > toml; `/captures` folds into `/crabidy`. Before that, download captures were
> tmp-and-swap described here. > already **partially superseded** by `incremental-captures.md`: they became
> incremental (re-capturing a name resumes; no tmp-and-swap), uncapturable
> tracks are recorded as *skipped* tomls instead of omitted, and the capture
> RPC streams progress. Bookmarks kept the tmp-and-swap described here.
## Context and problem statement ## Context and problem statement

238
architecture/cli.md Normal file
View File

@ -0,0 +1,238 @@
# A comprehensive CLI for every binary
## Context and problem statement
Today the command line is thin and inconsistent:
- `crabidy-server` uses clap-derive but exposes only `hash-password` and no
flags (it always binds the fixed `LISTEN_ADDR`).
- `cbd-tui` and `cbd` share a ClapSerde `Config` (`-a/-u/-p`, `--spectrum`)
that doubles as the TOML schema; neither has subcommands.
- Every user operation is reachable *only* through the interactive TUI. There
is no way to script the player, set up auth, or index a music folder from the
shell.
The goal: **every binary is a clap-derive CLI with `--help`; every operation
the TUI can do is also a subcommand; there are shell completions and man pages;
and running a binary with no subcommand behaves exactly as today** (TUI, run the
server, or `cbd`'s server+TUI).
New capabilities requested:
- **`guard`** (server): hash a role password and, by default, write it into
`crabidy-server.toml`'s `[auth]`.
- **`scan`** (server): walk a path, drop a `.cbd-track.toml` beside every
playable file; `--capture` copies the audio into the content store (toml
points there), `--move` moves it instead of copying.
- **`auth`** (client): write a role + cleartext password into the client config.
- **`library` / `queue` / `global`** (server *and* client): the full set of
library, queue, and playback operations, run against a server over gRPC.
## Assumptions
- The `library`/`queue`/`global` commands are **remote**: they connect to a
running server at `--address` (default localhost) with the same basic-auth as
the TUI, reusing the generated gRPC client. A "server" binary running them is
just acting as a client to whatever server is up — including its own.
- One config file per client binary stays the source of truth for connection
defaults (`cbd-tui.toml`, `cbd.toml`); flags override it.
- Passwords may be given as an argument *or* on stdin. Argv is visible in the
process list — the help text says so, and omitting the argument reads stdin
(pipe-friendly, the current `hash-password` behavior).
- No tag extraction in `scan` v1 (title = file stem); no machine-readable
output format v1 (human-readable text). Both are noted as future work.
## D1 — A shared `cbd-cli` crate
A new library crate **`cbd-cli`** holds the clap definitions so all three
binaries share one command surface and each binary's `build.rs` can generate
assets from it. It is feature-split to keep `build.rs` light:
- **default features (clap only)**: the `Parser`/`Subcommand` types
(`LibraryCmd`, `QueueCmd`, `GlobalCmd`, `GuardCmd`, `ScanArgs`, `AuthCmd`, the
per-binary top-level `ServerCli`/`TuiCli`/`CbdCli`), a `Role` enum, a
`RemoteArgs` flatten (`--address/--user/--password`), and
`generate_assets(cmd, out_dir)` (wrapping `clap_complete` + `clap_mangen`).
Depends only on `clap`, `clap_complete`, `clap_mangen`.
- **feature `client`**: `run_remote(remote: &RemoteArgs, cmd: RemoteCmd)` — the
executor that connects a `crabidy_core` gRPC client (with a standalone
basic-auth interceptor) and runs a `library`/`queue`/`global` subcommand,
printing results. Adds `tonic`, `crabidy-core`, `tokio`.
`guard`/`scan`/`auth` *definitions* live in `cbd-cli` (so completions and man
pages cover them) but their *execution* lives in the owning binary, which has
the server config / store / client config internals `cbd-cli` must not depend
on.
```d2
direction: right
cbd_cli: cbd-cli (clap defs + asset gen) {
defs: "Parser/Subcommand types\nRemoteArgs, Role\ngenerate_assets()"
client: "feature=client:\nrun_remote() gRPC executor"
}
server: crabidy-server {
guard_scan: "guard + scan\n(config writer, store)"
}
tui: cbd-tui {
auth: "auth (client config writer)"
}
cbd: cbd (bundle)
core: crabidy-core (generated client)
cbd_cli.defs -> server.guard_scan: defines
cbd_cli.defs -> tui.auth: defines
cbd_cli.client -> core: gRPC to a running server
server -> cbd_cli.client: library/queue/global
tui -> cbd_cli.client: library/queue/global
cbd -> server: reuses guard/scan
cbd -> tui: reuses auth
cbd -> cbd_cli.client: library/queue/global
```
## D2 — Per-binary CLI and the no-subcommand default
Each binary defines a top-level clap `Parser` (in `cbd-cli`) with global
connection flags and an **optional** subcommand:
- `ServerCli`: `[guard|scan|library|queue|global|completions]`; none → run the
server (as today).
- `TuiCli`: `RemoteArgs` + `--spectrum` + `[auth|library|queue|global|
completions]`; none → run the TUI.
- `CbdCli`: the union — `[guard|scan|auth|library|queue|global|completions]`;
none → server + TUI (as today). This is "cbd has the commands from both."
**Config merge.** The no-subcommand path must keep today's behavior: read the
TOML (writing defaults on first run) and let flags override. ClapSerde did this
implicitly; a top-level `Parser` with a subcommand does not compose cleanly with
ClapSerde's `Opt`. Decision: replace ClapSerde for the client binaries with a
plain `serde` config load plus an explicit override step — the `RemoteArgs`
fields are `Option`, and a provided flag overrides the file value. `init_config`
keeps writing a defaults file on first run. This is a small, well-contained
change to `cbd-tui/src/config.rs` and the two `main.rs` files.
Alternative considered: sniff argv for a known subcommand before ClapSerde
parsing and branch. Rejected — it forfeits a unified `--help`/completions and is
fragile.
## D3 — `library` / `queue` / `global` (remote)
The executor (`cbd-cli` feature `client`) maps subcommands to the existing RPCs
(`RpcClient` already wraps all but `Stop`, which gains a wrapper):
- `library list [PATH]` (default `/`) → `GetLibraryNode`; prints child nodes and
tracks (path, title; captured rows marked, per architecture/crabidy-store.md).
- `library create <PARENT> <TITLE>`, `rename <PATH> <TITLE>`,
`delete <PATH>` → the matching library RPCs.
- `library save <PATH> <NAME>``CaptureLibraryNode{download:false}`;
`library capture <PATH> <NAME>``{download:true}`.
- `queue show``Queue`; `queue append|insert|replace <PATH>…`,
`queue remove <POS>…`, `queue clear [--keep-current]`,
`queue set-current <POS>`, `queue save <NAME>`, `queue capture <NAME>`
(→ `CaptureLibraryNode` on `/crabidy/current`), `queue shuffle`,
`queue repeat` (the `QueueModifiers` toggles).
- `global play|stop|next|prev|restart|mute`, `global volume <DELTA>` (or
`up|down` sugar around `ChangeVolume`).
Connection: `RemoteArgs` → a lazily-connected `CrabidyServiceClient` with a
basic-auth interceptor built from `--user/--password` (empty user = no header,
i.e. an open server). Each command is a one-shot: connect, call, print, exit.
`RpcClient::connect`'s `&'static ServerConfig` signature is loosened (or the
executor builds the client directly) so a CLI can pass an owned config.
Output is human-readable text; a `--json` flag is future work (D9).
## D4 — `guard` (server)
`crabidy-server guard <ROLE> [PASSWORD] [--no-config]` where `ROLE`
`owner|queue-owner|appender`:
1. Read the password from the argument, or from stdin if omitted.
2. Hash it (`auth::hash_password`, argon2id) and **print the PHC string** to
stdout (so it stays pipeable and `--no-config` reproduces today's
`hash-password`).
3. Unless `--no-config`: load `crabidy-server.toml` (or defaults), set the role's
field (`owner`/`queue_owner`/`queue_appender`) to the hash, and write it back,
preserving the flat `[auth]` shape and the other roles. This needs a **config
writer** (D8) — none exists today.
`hash-password` is removed in favor of `guard` (`guard owner <pw> --no-config`
is the exact replacement). The role names match the basic-auth user names.
## D5 — `scan` (server)
`crabidy-server scan <PATH> [--capture] [--move]`:
- Walk `<PATH>` recursively (bounded, skipping hidden entries), selecting files
by audio extension (flac/mp3/m4a/ogg/opus/wav/webm/aac…).
- For each audio file, write a `<stem>.cbd-track.toml` **beside it** using
`fsdy::TrackFile` (`to_toml`, the shared naming), so the folder becomes a
browsable `/fs` tree. Title defaults to the file stem (tag extraction is
future work).
- Default (neither flag): the toml's playable is `Playable::File` pointing at
the audio's own file name (relative) — the audio stays where it is.
- `--capture`: ingest the audio into the content store (hash + de-dup + sidecar,
reusing `CrabidyStore`), and the beside-file toml gets a `Playable::Store`
entry instead. Idempotent: an already-stored file de-dups.
- `--move`: like `--capture` but the source audio is moved into the store, not
copied — the original location keeps only the toml.
- `--capture`/`--move` require the content store (a state/data dir);
`scan` opens a `CrabidyStore` directly and calls a new `ingest_file(path,
move) -> StoreName` helper (the local-source half of the D4 capture flow,
factored out).
Existing `.cbd-track.toml` files are left untouched (scan never clobbers a
hand-edited toml); a warning notes skips.
## D6 — `auth` (client)
`cbd-tui auth <ROLE> [PASSWORD] [--address ADDR]` (and the same on `cbd`):
loads the client config (`cbd-tui.toml` / `cbd.toml`), sets `user` to the role
name and `password` to the cleartext (optionally `address`), and writes it back
via the **client config writer** (D8). The file is chmod-private-friendly; the
help text repeats that the client config holds a plaintext password.
## D7 — Shell completions and man pages
Add `clap_complete` and `clap_mangen`. Each binary's `build.rs` build-depends on
`cbd-cli` (default features — clap only, cheap) and, from its top-level
`Command`, writes bash/zsh/fish completions and a `man` page into `OUT_DIR`
every build (a genuine build step). When `CBD_ASSET_DIR` is set, `build.rs` also
copies them into that stable directory. A devenv `gen-cli-assets` script sets
`CBD_ASSET_DIR=$PWD/dist` and builds, so `dist/completions/**` and `dist/man/*.1`
are produced on demand. A hidden `completions <shell>` subcommand on each binary
prints a completion script to stdout for ad-hoc use.
Alternative considered: an `xtask` generator binary. Rejected — a `build.rs`
keeps generation automatic and in lockstep with the CLI definition; the
`CBD_ASSET_DIR` copy covers the "get them into the repo" need.
## D8 — Config writers (new)
Two small, careful serializers, both load-modify-write preserving shape:
- **Server** (`crabidy-server`): `ServerSettings` gains a `store(config_dir)`
that serializes the current `[auth]` (round-tripping the existing file so
unknown-field rejection stays satisfiable) — used by `guard`.
- **Client** (`cbd-tui`): the `Config`/`ServerConfig` is already `Serialize`;
`auth` loads it, sets the fields, and writes `toml::to_string_pretty` back to
the config path — used by `auth`.
Both write to `dirs::config_dir()/crabidy/<file>` and create it if missing.
## D9 — Out of scope / future
- Tag extraction in `scan` (a `lofty`-backed title/artist/album/duration).
- `--json` machine-readable output for `library list` / `queue show`.
- Watching/streaming (`global watch` over `GetUpdateStream`).
- Bulk auth (multiple roles in one `guard` invocation).
## Risks
- **Password in argv** is visible process-wide; mitigated by the stdin fallback
and documented in help. Accepted per the explicit request.
- **ClapSerde → clap migration** for the client config changes the parse path;
the first-run-writes-defaults and flag-overrides-file behaviors must be
preserved by tests.
- **`build.rs` writing outside `OUT_DIR`** (the `CBD_ASSET_DIR` copy) is
unconventional; gated on the env var so ordinary builds only touch `OUT_DIR`.
- **`cbd-cli` as a build-dependency** must stay clap-only by default, or every
build drags in tonic — enforced by the feature split.

View File

@ -0,0 +1,347 @@
# The `crabidy` provider and the content-addressed store
## Context and problem statement
Today the server exposes three hand-managed subtrees — `/queues`, `/bookmarks`,
`/captures` — each a separate `fsdy::Client` instance rooted under
`~/.config/crabidy/`. Bookmarks (`w`) write *link* tomls; captures (`W`)
download audio next to each toml (`capture-deletion.md`,
`incremental-captures.md`). This has three problems the user wants fixed
green-field (no data migration):
1. **No de-duplication.** Capturing the same track from two places (a playlist
and a search, or two saved queues) downloads and stores the audio twice.
Re-capturing after a reorder re-downloads everything.
2. **Audio lives next to metadata.** Deleting a capture folder must carefully
remove downloaded audio from disk behind a confirmation
(`capture-deletion.md`), because the audio is only referenced from that one
folder. This couples deletion to expensive-payload bookkeeping.
3. **Three UI concepts** (queues, bookmarks, captures) for what the user thinks
of as "my crabidy stuff." They want one provider.
This design replaces all three with **one filesystem provider, `/crabidy`**,
whose track tomls *link into a single content-addressed store* of playable
files. Captures de-duplicate by provider identity and by content hash; deletion
becomes a plain toml removal that never touches the store.
This **supersedes** `bookmarks.md`, `captures.md`, `capture-deletion.md`, and
the on-disk/resumption parts of `incremental-captures.md` (the skipped-track and
progress-stream parts of that doc survive; see D9).
## Assumptions (confirmed by the request)
- **Green-field.** No migration of existing `~/.config/crabidy/{queues,
bookmarks,captures}` data. On first run the new locations are simply empty.
- **Two roots, split by XDG kind.** The store (playable audio + its sidecars)
is *data*`~/.local/share/crabidy/` (`dirs::data_dir()`). The tomls (the
`/crabidy` tree) are *state*`~/.local/state/crabidy/` (`dirs::state_dir()`).
- **Single server, single writer.** One process owns both roots; capture
mutations are serialized. No cross-host concurrent writers.
- **The store never shrinks automatically.** Deleting a toml never deletes store
audio (D7). Orphaned store entries are accepted; a garbage collector is future
work (D10).
- **`current` stays special.** The live queue is still mirrored to a reserved,
user-untouchable folder — now `/crabidy/current`, flat as before.
## D1 — One provider: `/crabidy`
`ProviderOrchestrator` drops the `queues`/`bookmarks`/`captures` fields and gains
one `crabidy` field: an `fsdy::Client` rooted at `~/.local/state/crabidy/`,
mounted at `/crabidy`, built `with_editable_top_level(&["current"])` (top-level
saves are renamable/deletable; `current` is reserved) `.with_downloadable_nodes()`
`.with_deletable_tree()` (every node deletes directly — see D7). A companion
writer, `CrabidyStore`, owns *both* roots and all mutation.
- Top-level folders under `/crabidy` are **user saves**, each created by `w`/`W`.
- **Saved queues are flat**; **saved library subtrees preserve their structure**
(falls out of walking the source — a flat queue yields a flat save).
- `/crabidy/current` is the live-queue mirror the playback loop keeps in sync
(replaces `/queues/current`). It is flat and reserved.
Non-toml files in a save folder (e.g. client log files that also land in
`~/.local/state/crabidy/`) are ignored by listing, as today — the provider only
surfaces subdirectories and `*.cbd-track.toml` files. (Optional tidy-up, not
required: move client logs to `~/.local/state/crabidy/logs/`.)
```d2
direction: right
orchestrator: ProviderOrchestrator (root, path-routed) {
tidal
youtube
fs
crabidy: /crabidy (fsdy::Client, read + delete)
}
state: "~/.local/state/crabidy/\n(toml tree)" { shape: cylinder }
share: {
label: "~/.local/share/crabidy/\ncontent store: audio + sidecars"
shape: cylinder
}
store_writer: CrabidyStore (single writer, owns both roots) {
index: "StoreIndex\n(provider-id → entry,\nhash → entry)"
}
orchestrator.crabidy -> state: lists tomls
orchestrator.crabidy -> share: resolves Store playables
store_writer -> state: writes save folders / track tomls
store_writer -> share: writes audio + sidecars
store_writer.index -> share: derived by scanning sidecars at open
```
## D2 — The store and its sidecars
`~/.local/share/crabidy/` is a **flat** directory. Each unique playable is a
pair:
- `<name>` — the audio file, named after the source's natural name (a local
file's basename, else a sanitized `<title>.<ext>` with the extension from the
download's `Content-Type`/URL). On name collision with *different* content,
append a numeral: `song.flac`, `song (2).flac`, … (identical content never
reaches naming — it de-dupes first, D4).
- `<name>.cbd-store.toml` — the sidecar, the store entry's metadata:
```toml
# song.flac.cbd-store.toml
hash = "blake3:1f0c…" # content hash of the audio file
[[provider]] # one entry per provider-id that maps here
provider = "tidal" # provider name (path root of the source)
id = "125169484" # provider-internal id (see D3)
title = "Bohemian Rhapsody"
artist = "Queen"
duration = 355
aliases = ["Bohemian Rhapsody (Remastered)"] # other titles for this id
[provider.album]
title = "A Night at the Opera"
release_date = "1975-11-21"
[[provider]] # same content reached via a second identity
provider = "youtube"
id = "fJ9rUzIMcZQ"
title = "Queen Bohemian Rhapsody (Official Video)"
```
The **sidecars are the single source of truth** — there is no separate persisted
index file. `CrabidyStore` builds an in-memory `StoreIndex` by scanning
`*.cbd-store.toml` at open and updates it on every write:
- `by_provider_id: HashMap<(provider, id), StoreRef>`
- `by_hash: HashMap<Hash, StoreRef>`
A `StoreRef` is the store `<name>` (which is the toml link target and the sidecar
key). Lookups are O(1).
> **"grep without shelling out."** The request describes searching sidecars
> for a provider id / hash like ripgrep, in-process. The `StoreIndex` *is* it,
> memoized: it is derived by reading the sidecars in-process at open (no shell,
> no external index), so the store stays self-describing. A live
> content-scan-then-parse (via the `grep-searcher` crate) was considered as the
> literal realization; rejected because an in-memory map built once is simpler,
> strictly faster for repeated captures in a session, and needs no new
> dependency. If the store ever grows beyond memory, a lazy content-scan is the
> fallback (D10).
## D3 — Provider identity on the wire
The store keys on a **provider-internal id** — "the id that clearly identifies
the item inside the provider," which the path cannot be (the same item is
reachable via a playlist, a search, an album…). Today no such id exists; identity
is the path string. We add it to the wire model:
- `Track.provider_item_id` (proto field 7, `string`) — set by the owning
provider when it produces a `Track`. Empty when unknown.
- **tidal** → the numeric track id.
- **youtube** → the video id.
- **fs** → the source file's canonical absolute path (two `/fs` tomls pointing
at the same file share an id → they de-dupe).
- **crabidy** (already store-backed) → the store `<name>`; used only to detect
"already captured" (D5).
- The **provider name** is the first path segment of the (resolved) source track
path — `to_track` already rewrites a link track's `path` to its target, so a
queued link routes to the real provider and carries that provider's id.
For the capture indicator (D8) we also add:
- `Track.is_captured` (field 8, `bool`) — the server sets it at listing time when
the store index has an entry for this track's `(provider, id)` **or** its
playable is already a store link. Cheap (one hashmap lookup) and works while
browsing *any* provider, so you can see what you have captured.
- `LibraryNode.is_captured` (field 10) and `LibraryNodeChild.is_captured`
(field 8) — a node is captured iff all its tracks and child nodes are captured
(D8 covers how the `/crabidy` provider computes this cheaply).
## D4 — Capturing one track: the de-dup flow
`store = true` means the track links into the store; `link` means a bookmark link
to the source provider. Capture (`W`) produces store links; bookmark (`w`)
produces plain links and never touches the store.
For each source track a capture walk visits, in order:
```d2
direction: down
start: "resolve source track\n(provider, id, natural name)"
already: "already store-backed?\n(Store playable, or fs file under the store root)"
byid: "index.by_provider_id[(provider,id)] ?"
getbytes: "obtain bytes\n(download → temp, or local file)"
byhash: "index.by_hash[hash(bytes)] ?"
newentry: "NEW: copy into store\n(+numeral) + write sidecar"
addid: "add provider to sidecar,\ndiscard the temp copy"
writetoml: "write track toml with playable.store = <name>"
start -> already
already -> writetoml: "yes → reuse target store name (no-op copy)"
already -> byid: "no"
byid -> writetoml: "HIT → reuse; record alias if title differs"
byid -> getbytes: "MISS"
getbytes -> byhash
byhash -> addid: "HIT (same content, new identity)"
byhash -> newentry: "MISS"
addid -> writetoml
newentry -> writetoml
```
1. **Already store-backed?** If the source track's playable is a `Store` link, or
it is an `/fs` file whose path is already under the store root, there is
nothing to fetch — the save's toml links to the same store `<name>`. (This is
the "capture on an already-captured fs item → do nothing" case.)
2. **Provider-id lookup.** `index.by_provider_id[(provider, id)]` — a hit means
we already have this exact provider item. Point the save's toml at that store
entry. If the current title differs from the stored one, append it to that
entry's `aliases`. **No download.** (Common re-capture path — makes redoing a
save cheap.)
3. **Miss → obtain bytes.** Streamed source: windowed HTTP download to a temp
file (unchanged mechanics). Local source (`/fs` pointing at a normal file, not
under the store): the file is the bytes. Hash the bytes (blake3).
4. **Hash lookup.** `index.by_hash[hash]` — a hit means identical content is
already stored under some other identity. **Add** a `[[provider]]` entry to
that sidecar, **discard** the temp download (or do not copy the fs file),
point the toml at the existing store name. No duplicate.
5. **Miss → new store entry.** Choose a store name from the natural name
(+numeral on collision), move the temp file (or copy the fs file) into the
store, write the sidecar with `hash` and the first `[[provider]]` entry, update
the index. The `/fs` case *copies* — the original music-folder file stays put.
The byte budget (`DOWNLOAD_CAPS.max_bytes`) still counts bytes fetched *this run*
(hits cost nothing), so dedup makes big saves cheaper, never starves them.
## D5 — Save (`w`/`W`) and conflict handling
A **save** takes a *source* (a live-queue snapshot or a library-node path) and a
*mode* (`Link` for `w`, `Capture` for `W`) and writes a new top-level folder
`/crabidy/<name>`:
- **`w`** on a library node or the queue → folder of **link** tomls
(`fsdy::TrackFile::from_track`), the bookmark semantics, no store, no audio.
- **`W`** on a library node or the queue → `w` **plus** run the D4 capture per
track; tomls carry `playable.store`. Works on both library nodes and the queue.
**Conflict handling.** Each save records its origin in a hidden
`.cbd-save.toml` marker at the save root (`source = "<captured node path>"`,
`capture = true|false`). When `/crabidy/<name>` already exists:
- **`w` (link save)** always refuses — do nothing and warn `name "<name>"
already exists`; the user deletes the old folder and saves again.
- **`W` (capture)** refuses **unless it is a re-capture of the same source**:
if the existing save's marker `source` equals the node being captured, the
save **replaces** it; a different source still refuses. So pressing `W`
again on the same item (or `W` on the queue again) refreshes the capture in
place, while `W` naming a *different* item after an existing save is still
protected.
The save is built in a hidden `.tmp-<name>` sibling and swapped in atomically;
a permitted replace removes the old folder and renames the temp over it. A
crashed/failed run leaves **no** blocking partial folder, and any audio already
committed to the store persists and makes a retry fast via D4 (resumability
lives in the store, not the folder). Replacing is cheap and safe because a
save folder holds only tomls — the shared store audio is never rewritten or
deleted (D7). A save that predates the marker (no `.cbd-save.toml`) is treated
as a different source and refuses; delete it once to re-establish it.
`current` is exempt: the playback loop overwrites it on every queue change; the
user cannot save over the reserved name `current`.
## D6 — RPC surface
- **`CaptureLibraryNode(path, name, download)`** stays and now covers *all four*
gestures: `w`/`W` on a library node (`path = /tidal/...`), and `w`/`W` on the
queue (`path = /crabidy/current`). `download=false``Link`, `true`
`Capture`. Validation errors (bad name, conflict, source not downloadable)
return synchronously; progress streams via the existing `CaptureProgress`
update (unchanged, D9).
- **`SaveQueue(name)` is retained** (this deviates from the original plan to
remove it — see `plan/summary.md`). It is the queue `w` gesture and is
reimplemented server-side as a *link* save of the live queue into
`/crabidy/<name>` (via `CrabidyStore::save_snapshot`). Queue `W`
(`QueueDownloadCapture`) goes through `CaptureLibraryNode` on
`/crabidy/current` with `download = true`.
- `DeleteLibraryNode(path)` unchanged in shape; behavior simplified (D7).
## D7 — Deletion
Deletion on `/crabidy` (the only writable fs provider) **goes through directly,
no confirmation, and never touches the store**:
- Delete a track → remove its `.cbd-track.toml` only.
- Delete a folder → `remove_dir_all` of the toml folder only.
The existing `fsdy::delete_track_file` guard — "only delete the referenced audio
if it is contained under the instance root" — already makes this safe: a
`Store` playable resolves under `~/.local/share/…`, which is *outside* the
`/crabidy` toml root at `~/.local/state/…`, so the audio is never deleted. And
`w`-saves are links with no audio at all.
Consequences: drop the TUI's `delete_needs_confirmation` / `ConfirmDelete` path
(the whole `capture-deletion.md` confirmation feature is gone — nothing expensive
is destroyed anymore) and drop `with_deletable_tree`'s audio-removal branch usage
for this provider (folder/toml removal remains).
## D8 — UI: collapse to one provider, mark captured nodes
- `/queues`, `/bookmarks`, `/captures` disappear from the root; one `/crabidy`
child appears (title `crabidy`). Inside it, `w`-saves (links) and `W`-saves
(store-backed) coexist, distinguished by the captured marker.
- **Captured marker:** a captured row carries a trailing `↓` at the *end of
the row* — a status marker after the action-key brackets (outside them),
e.g. `Bohemian… ↓`.
- A **track** row is captured per `Track.is_captured` (D3): store-backed, or its
`(provider, id)` is in the store index — visible even while browsing tidal.
- A **node/child** row is captured iff all its tracks and child nodes are
captured. The `/crabidy` provider computes a node's own `is_captured` when it
lists it (it reads every track toml anyway → all `Store`?). To mark child
*folders* in a parent listing without deep recursion, a save records its mode
in a one-line marker at the save root written by `w`/`W`; nested folders under
a `W`-save are captured by construction. Exact recursion depth is an
implementation detail (see plan) — the invariant is "captured = fully local."
- The existing capture **progress** lines (`capturing <name> 12/34 …`) stay as-is.
## D9 — What carries over from `incremental-captures.md`
Kept: the `Skipped` playable (D1 there), skipped tracks in the queue and playback
skipping them (D3), the `CaptureProgress` stream and accept-then-stream RPC (D4),
and the focused-selection contrast fix (D7). A source that genuinely cannot be
fetched still writes a skipped toml. What changes: the *store write mode* (audio
now goes to the shared store, tomls carry `store`, dedup per D4) and *resume
semantics* (folder is atomic; store provides the savings, per D5).
## D10 — Out of scope / future
- **Store garbage collection.** Nothing reclaims store entries whose last
referencing toml was deleted. A future GC would scan all `/crabidy` tomls for
live `store` names and remove unreferenced pairs; needs its own design.
- **Lazy content-scan** instead of the in-memory index, for stores too large to
index in memory (D2).
- **Cross-provider captured marking of folders** (e.g. a tidal album shown
captured) beyond the cheap track-level lookup.
- **Cancelling a running capture** (unchanged from prior scope).
## Risks
- **Wasted download on a hash-only match** (provider-id missed but content is
identical): we download, then discard. Unavoidable for content dedup; the
provider-id path avoids it in the common case.
- **Natural-name collisions** across unrelated tracks are handled by the numeral
suffix; the store name is opaque to users (only the toml title shows in UI).
- **Index/disk skew** if something outside the server edits the store: the index
is rebuilt at every start, and the server is the sole writer, so skew is
bounded to a single run — acceptable.
- **blake3 dependency** added (fast, maintained, no C toolchain). Alternative
`sha2` rejected for speed; hashing whole tracks is on the capture hot path.

View File

@ -0,0 +1,216 @@
# fyyd provider (podcasts)
## Context and problem statement
A new library provider mounted at `/fyyd` that lets a user **find and play
podcasts**, backed by [fyyd](https://fyyd.de)'s public search engine.
- **Search** works with no account — creatable search-term nodes exactly
like `/tidal/search` and `/youtube/search` (`%` creates a term, results
appear underneath).
- Unlike YouTube, a podcast search does **not** return tracks directly: it
returns *podcasts*, each of which is a container of *episodes*. So the
tree carries **one extra level**`search-term → podcast → episodes`
where YouTube is `search-term → tracks`.
- **Playing** an episode means streaming its `enclosure` URL (the plain
HTTP(S) audio file from the podcast's RSS feed). That is exactly the
URL-returning shape the audio player already handles; no sidecar, no
cipher solving, no byte-proxying (contrast `/youtube`).
- **Captures** (`W`, download) come for free: any node that serves tracks
and raises `is_downloadable` gets `w`/`W` with no wire or TUI work.
## The fyyd API (grounding)
Base `https://api.fyyd.de/0.2/`, no key or auth for search and browse.
Responses are wrapped in a JSON envelope `{ "status", "msg", "data", … }`;
list endpoints add `meta.paging`. The endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Search podcasts | `GET /search/podcast?term=<t>&count=<n>` |
| Podcast episodes | `GET /podcast/episodes?podcast_id=<id>&count=<n>` |
| Hot (featured) podcasts | `GET /feature/podcast/hot?count=<n>` |
| Single episode | `GET /episode?episode_id=<id>` |
Objects (fields we read):
- **podcast**: `id` (int), `title`, `xmlURL`, `imgURL`, `description`,
`language`.
- **episode**: `id` (int), `title`, `guid`, **`enclosure`** (audio URL),
`podcast_id`, `duration` (seconds), `pubdate`.
The episode's `enclosure` is the only field playback needs. The single
episode endpoint does not carry the podcast's title, so an episode fetched
on its own has no "artist" until we look the podcast up (see D4).
## Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by `/youtube`): mirroring tidal's search-term semantics costs
no TUI or wire change. No proto change, no new `ProviderCommand`.
- fyyd's public API needs no credentials, so — unlike tidal — a missing or
empty `fyyd.toml` is the normal case, and init never needs a login. The
provider is non-fatal at startup like the local providers: a build/parse
failure only costs the `/fyyd` subtree.
- The audio player streams plain https `enclosure` URLs directly
(`audio-player` windowed-HTTP path). Podcast enclosures are ordinary
media files, so no `/youtube`-style URL-lifetime or ~1 MiB-cap problem.
- Episodes are addressed by fyyd's stable numeric `id` (as a string
segment). We do not use the RSS `guid` as the path key — the fyyd id is
shorter, already URL-safe, and is what the episode endpoint takes.
## Decisions
### D1 — Crate `fyyd`, mounted at `/fyyd`, non-fatal init
New workspace crate `fyyd` implementing `ProviderClient`, shaped on `ytdy`
(the closest analog: remote, search-driven, in-memory terms). Wired into
`ProviderOrchestrator` with a `fyyd_client: Option<Arc<fyyd::Client>>`
field, `fyyd_owns()`/`fyyd_provider()` helpers, a `build()` block that
reads `fyyd.toml` (non-fatal), a `get_lib_root` child gated on
`self.fyyd_client.is_some()`, and one routing arm in each dispatch method.
`crabidy-server`'s settings gain `fyyd` in `ALL_PROVIDERS`, in
`ProviderToggles`, in `all()`, and in `provider_toggles()`.
### D2 — HTTP behind a trait, faked in tests
All network access goes through one seam — a `Fyyd` trait (`search_podcasts`,
`hot_podcasts`, `podcast_episodes`, `episode`) behind `Box<dyn Fyyd>` — with
a `reqwest`-based `FyydApi` for production and a `FakeApi` in tests, exactly
as `ytdy` hides `rustypipe` behind `Extract`. Provider logic (tree shaping,
path parsing, term store) is then unit-tested with zero network. The trait's
error type maps to `ProviderError::FetchError` at the boundary; malformed
paths are `MalformedPath`; empty create/rename input is `InvalidInput`.
### D3 — Tree shape (the extra level)
- `/fyyd` — children: `search` (always, `is_creatable`), `hot` (always, a
fixed featured-podcasts browse so the provider is useful with zero
typing).
- `/fyyd/search``is_creatable`; children are the in-memory search terms
(`RwLock<Vec<String>>`, dedup, recreated implicitly on stale paths),
each `is_editable` + `is_deletable`, like tidal/youtube.
- `/fyyd/search/<term>` — lists the top-N matching **podcasts** as
children (containers, not tracks). Not directly queueable itself; each
podcast child *is* queueable.
- `/fyyd/search/<term>/<podcast-id>` — lists that podcast's episodes as
**tracks**; queueable and downloadable (homogeneous tracks). Queueing the
podcast node enqueues its listed episodes (bounded, see D5).
- `/fyyd/search/<term>/<podcast-id>/<episode-id>` — the track leaf.
- `/fyyd/hot` — lists featured podcasts as children (same podcast shape as
a search-term node, but the list comes from `/feature/podcast/hot`
instead of a term).
- `/fyyd/hot/<podcast-id>` and `/fyyd/hot/<podcast-id>/<episode-id>`
identical podcast/episode shapes as under `search`; the two branches
share the podcast-node and episode-leaf builders.
Terms are percent-encoded into one segment (`encode_segment`/
`decode_segment`); podcast and episode ids are already URL-safe integers.
### D4 — Streams, metadata, artist
- `get_urls_for_track`: parse the episode id from the path →
`episode(id)``vec![enclosure]`. An empty/missing enclosure is
`FetchError`, not a panic; the queue skips it.
- Track fields: `title` = episode title, `artist` = the **podcast** title,
`duration` = episode duration (seconds → `Option<u32>`),
`provider_item_id` = fyyd episode id (keys the content store for
captures), `album` = `None`.
- **Artist source.** When episodes are listed under a podcast we already
hold the podcast title, so listed tracks get the right artist for free.
A *directly* fetched episode (`get_metadata_for_track` on a bare episode
path, or a stream resolve) does not carry the podcast title from the
episode endpoint; the `FyydApi` fills it with one extra `/podcast`
lookup, and the `Episode` model carries `artist: Option<String>` so the
fake can supply it in tests. A missing artist degrades to an empty
string, never an error.
### D5 — Bounds and freshness
- `search_results` (podcasts per term, default 20), `hot_count` (default
20), and `episodes_per_podcast` (default 100) are configurable in
`fyyd.toml`; every listing is capped so a huge podcast cannot stall the
library or queue resolution. A `call_timeout_secs` (default 30) bounds
each HTTP call (hard rule: timeouts on external calls).
- Listings are fetched fresh per call (no cross-call cache), like tidal's
and youtube's fresh remote calls. Only the search *terms* are stored, in
memory.
### D6 — Out of scope (explicitly)
- fyyd user accounts, OAuth, subscriptions, personal collections, or the
"hot languages" / category browse — search + hot cover the "find and
play" ask.
- Episode chapters, transcripts, or per-episode images in the queue.
- Pagination past the configured caps (one page is fetched per listing).
- Caching or persisting episodes to disk beyond the existing `W` captures.
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
fyyd: "fyyd (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory, like tidal)"
api: "FyydApi\n(reqwest seam: Fyyd trait)"
client -> terms
client -> api
}
svc: "api.fyyd.de\n(public, keyless)" { shape: cloud }
cdn: "podcast enclosure\n(RSS media host)" { shape: cloud }
player: audio-player { shape: hexagon }
server.orch -> fyyd.client: "/fyyd/..."
fyyd.api -> svc: "search / episodes / hot (JSON, timeout)"
server.orch -> player: "enclosure URL"
player -> cdn: "windowed HTTP stream"
```
## Key flow: search a podcast, play an episode
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
f: fyyd
api: api.fyyd.de
tui -> orch: "% on /fyyd/search: 'history'"
orch -> f: "create_lib_node"
f -> api: "GET /search/podcast?term=history"
api -> f: "podcasts (id, title)"
f -> tui: "term node: podcasts as children"
tui -> orch: "open a podcast"
orch -> f: "get_lib_node(/fyyd/search/history/<pid>)"
f -> api: "GET /podcast/episodes?podcast_id=<pid>"
api -> f: "episodes (id, title, enclosure, duration)"
f -> tui: "podcast node: episodes as tracks (queueable)"
tui -> orch: "queue + play an episode"
orch -> f: "get_urls_for_track(.../<eid>)"
f -> api: "GET /episode?episode_id=<eid>"
api -> f: "enclosure URL"
orch -> orch: "player streams the enclosure"
```
## Risks and open questions
- **fyyd envelope / field drift.** The `data` envelope and field names
(`enclosure`, `xmlURL`, numeric `id`) are read from the current public
docs but were not live-validated during design. The `FyydApi` decodes
defensively (serde with `#[serde(default)]`, missing fields degrade, no
panic) and every failure is a typed `FetchError`; if a field name is
wrong the fix is local to `FyydApi`'s DTOs. **Live validation is a
task-plan gate.**
- **Episode enclosure availability / expiry.** Some feeds proxy or expire
enclosures; a dead URL surfaces as a skipped track, never a crash.
- **Artist double-fetch.** Filling a directly-fetched episode's artist
costs one extra `/podcast` call; acceptable because direct metadata
fetches are rare (listing is the common path and already has the title).
- **Rate limits.** fyyd does not document limits; the per-call timeout and
the absence of background polling keep request volume to user actions.

View File

@ -0,0 +1,265 @@
# jamendo provider (free / Creative-Commons music)
## Context and problem statement
A new library provider mounted at `/jamendo` that lets a user **search and
play** the Jamendo catalogue — hundreds of thousands of Creative-Commons
tracks — and browse the album a track belongs to, with **capture/download**
coming for free.
Jamendo is a **remote, search-driven** streaming service like
tidal/soundcloud/fyyd, but it is the *easy* one, and the design leans on that:
- **It has a real, stable, official API** (`api.jamendo.com/v3.0`). Unlike
SoundCloud there is **no `client_id` scraping and no rotation**: the developer
registers a `client_id` once at `devportal.jamendo.com` and drops it in
`jamendo.toml`. Every request just carries `client_id` + `format=json`.
- **The public catalogue needs no login.** Browsing and streaming are anonymous
with only a `client_id`; OAuth 2.0 exists solely for a user's own account
(favourites, personal playlists) and is **out of scope for v1**. So there is
no token lifecycle, no optional-login branch — the whole provider is one flat
public surface.
- **Tracks stream as a plain MP3 URL.** Each track object carries an `audio`
field that is a direct, range-streamable MP3 (`mp3d.jamendo.com/...`). crabidy
streams a single byte source over its existing windowed-HTTP path, so —
unlike SoundCloud's HLS work — **there is no `audio-player` change at all**.
This is the `absdy` shape: nodes serve tracks, `get_urls_for_track` returns a
URL, the existing MP3 (symphonia) decoder handles the rest.
- **Duration is already in seconds**, which is exactly the unit
`Track.duration` carries (soundclouddy divides its ms by 1000 at
`lib.rs:436`; absdy passes seconds straight through). No conversion, no
repeat of the web ms/seconds bug.
- **Captures** (`W`, download) come for free once nodes serve tracks and raise
`is_downloadable`: Jamendo tracks carry an `audiodownload` URL and the
content is CC-licensed and explicitly downloadable. No wire or TUI work.
## The Jamendo API (grounding)
Base `https://api.jamendo.com/v3.0`. **Every** request carries
`client_id=<id>&format=json` (omitted from the cells below). List calls add
`&limit=<n>&offset=<o>`; `limit` max is **200** (default 10). Endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Search tracks | `GET /tracks?search=<t>` (or `namesearch`, `tags`) |
| Track detail (stream URL) | `GET /tracks?id=<id>` |
| Search albums | `GET /albums?namesearch=<t>` |
| Album's tracks | `GET /albums/tracks?id=<album_id>` |
Objects (fields we read):
- **track**: `id` (numeric string), `name` (→ title), `artist_name`,
`album_name`, `duration` (**seconds**), `audio` (direct streaming MP3 URL),
`audiodownload` (download URL), `license_ccurl`. `audioformat=mp32` requests
the higher-bitrate stream (default is a low-bitrate `mp31`).
- **album**: `id`, `name`, `artist_name`; `/albums/tracks` returns the album
wrapping a `tracks[]` array of the same track shape.
**A `User-Agent` header is mandatory** (live-discovered 2026-07-24): Jamendo's
API returns HTTP 200 `success` with an **empty** result set to any request that
carries none — and `reqwest` sends none by default — so `JamApi` sets one. This
is silent (no error), so a missing UA looks exactly like "no matches".
Search parameters: `search` (free text across track/album/artist/tags),
`namesearch` (name match), `tags` (AND) / `fuzzytags` (fuzzy OR),
`order` (relevance, popularity, downloads, listens, releasedate, …). v1 uses
`search` with the default relevance order.
## Assumptions (decided here)
- The captures / creatable / editable / deletable TUI + wire flows are
provider-agnostic (proven by `/youtube`, `/fyyd`, `/abs`, `/soundcloud`):
a search-term provider costs **no proto, wire, TUI, or `ProviderCommand`
change**. It is a pure path-prefix subtree.
- The Jamendo `audio` URL is a real streaming MP3 the existing windowed-HTTP
source plays unmodified — **no HLS, no new `audio-player` component**. (Risk
R1 gates this with a live play.)
- A missing / malformed / rejected `client_id` must **never crash startup or a
browse**. With no `client_id` the `/jamendo` subtree is simply **not
mounted** (like `/abs` with missing config); a `client_id` that the API later
rejects surfaces a typed error and degrades the subtree, never the app.
- `client_id` is a semi-secret account key: **redact it from `Debug`, config
dumps, and logs**, and never log a built stream URL (they can embed a signed
`from` token). (Hard rule: redact secrets.)
- Jamendo ids are numeric and URL-safe; only user-typed **search terms** are
percent-encoded into a path segment.
## Decisions
### D1 — Crate `jamendody`, mounted at `/jamendo`, non-fatal init
New workspace crate `jamendody` implementing `ProviderClient`, shaped on
`absdy`/`soundclouddy` (remote, search-driven, plain leaf tracks with direct
URLs). Wired into `ProviderOrchestrator` with a
`jamendo_client: Option<Arc<jamendody::Client>>` field, `jamendo_owns()` /
`jamendo_provider()` helpers, a `build()` block that reads `jamendo.toml`
(non-fatal — absent or `client_id`-less ⇒ `None`), a `get_lib_root` child gated
on `self.jamendo_client.is_some()`, and one routing arm in each dispatch method.
`crabidy-server` settings gain `jamendo` in `ALL_PROVIDERS` (8 → 9), in
`ProviderToggles`, in the defaults, and in `provider_toggles()`. No
`cli.rs` / `main.rs` change.
### D2 — HTTP behind a trait, faked in tests
All network access goes through one seam — a `Jam` trait
(`search_tracks`, `search_albums`, `album_tracks`, `track_detail`) behind
`Box<dyn Jam>` — with a `reqwest`-based `JamApi` for production and a `FakeApi`
in tests (as `absdy` hides `reqwest` behind `Abs`, `soundclouddy` behind `Sc`).
Provider logic (tree shaping, path parsing, term store) is unit-tested with zero
network. Errors map to `ProviderError::FetchError` at the boundary; malformed
paths → `MalformedPath`; empty create/rename → `InvalidInput`. Every call is
bounded by `call_timeout_secs` (D5, hard rule: timeouts on external calls).
### D3 — Tree shape (search → tracks + albums; canonical leaves)
Track and album ids are both numeric, so canonical paths are **type-tagged** to
disambiguate: `track/<id>` (leaf) and `album/<id>` (container). Browse nodes
point their children at these canonical paths, so playback and album expansion
never depend on the branch they were reached through.
- `/jamendo` — child: `search` (creatable). Not itself queueable.
- `/jamendo/search``is_creatable`; children are the in-memory search terms
(`RwLock<Vec<String>>`, dedup), each `is_editable` + `is_deletable`, exactly
like the tidal/youtube/soundcloud search stores.
- `/jamendo/search/<term>` — the results: matching **tracks** as queueable
leaves (pointing at `/jamendo/track/<id>`) and matching **albums** as
queueable containers (pointing at `/jamendo/album/<id>`).
- `/jamendo/album/<id>` — the album's tracks (queueable, downloadable); the
canonical container path.
- `/jamendo/track/<id>` — the canonical **track leaf**. A track id alone
resolves a stream, so every branch's track children point here and playback
needs no browse context.
Terms are percent-encoded into one segment (`encode_segment` / `decode_segment`,
shared helpers already used by the other search providers); numeric ids are
already URL-safe.
### D4 — Playback: direct MP3, no player change
`get_urls_for_track(/jamendo/track/<id>)`: `GET /tracks?id=<id>&audioformat=…`,
read `audio`, and **return it as `urls[0]`** (the player consumes only the
first). One API round-trip because the URL can embed a signed token; a track
with no `audio` (unstreamable) surfaces `NotStreamable`/`NotFound` and is
skipped, never a crash. The URL is a normal HTTP MP3 → the existing
`WindowedHttpStream` + symphonia MP3 decoder play it unchanged; `open_source`
routing is untouched. Track fields: `title` = `name`, `artist` = `artist_name`,
`album` = `Album { title: album_name }` when present else `None`,
`duration` = `duration` seconds → `Option<u32>` (filtered `> 0`),
`provider_item_id` = `"track:<id>"` (keys the capture store), and nodes/tracks
raise `is_downloadable` (backed by `audiodownload`).
### D5 — Auth and bounds
- `Settings`: `client_id: String` (**required** — without it the provider does
not mount), `audioformat: Option<String>` (default `mp32`),
`search_results: usize` (default 50, capped at the API's 200),
`album_tracks_limit: usize` (default 200), `call_timeout_secs: u64`
(default 30). Hand-written `Debug` redacts `client_id`.
- No scraping, no OAuth, no token refresh in v1 — the `client_id` is read once
from config and used on every call. An API `401`/`403` (revoked/invalid key)
maps to a typed `FetchError`; the subtree degrades, the app survives.
- Caps are `log`-ged when they truncate a listing, so truncation is visible,
not silent (hard rule: no silent caps). Listings are fetched fresh per call
(no cross-call cache), like the other remote providers; only search terms are
held in memory.
### D6 — Out of scope (explicitly)
- **OAuth user features**: personal favourites, a user's own playlists, and
writing to a Jamendo account. Additive later behind an optional token,
mirroring the soundcloud "login optional" branch.
- **Tag / genre / popular / radio browse** and **artist browse**: v1 is
search-driven (`search` → tracks + albums). Tag and popularity browse nodes
are a clean phase-2 add (same DTOs, new root children).
- **Pagination past the configured caps** (one page per listing).
- **Download-format negotiation** beyond the single `audioformat` setting.
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
jam: "jamendody (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms\n(in-memory RwLock<Vec>)"
api: "JamApi\n(reqwest seam: Jam trait)"
client -> terms
client -> api
}
player: "audio-player" {
http: "WindowedHttpStream\n(existing, unchanged)"
dec: "rodio / symphonia (mp3)"
http -> dec: "mp3 bytes"
}
japi: "Jamendo api.jamendo.com/v3.0" { shape: cloud }
cdn: "mp3d.jamendo.com (MP3)" { shape: cloud }
server.orch -> jam.client: "/jamendo/..."
jam.api -> japi: "search / tracks / albums (JSON, client_id, timeout)"
server.orch -> player.http: "audio MP3 URL"
player.http -> cdn: "GET mp3 (range)"
```
## Key flow: search and play a track
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
j: jamendody
api: "Jamendo api-v3"
http: "WindowedHttpStream"
cdn: "mp3d.jamendo.com"
tui -> orch: "open /jamendo/search"
tui -> orch: "create term \"lofi piano\""
orch -> j: "create_lib_node(search, term)"
j -> tui: "term stored"
tui -> orch: "open /jamendo/search/<term>"
orch -> j: "get_lib_node"
j -> api: "GET /tracks?search=…&client_id="
api -> j: "tracks (id, name, artist, album, duration s, audio)"
j -> tui: "tracks as leaves (/jamendo/track/<id>) + albums"
tui -> orch: "queue + play a track"
orch -> j: "get_urls_for_track(/jamendo/track/<id>)"
j -> api: "GET /tracks?id=<id> → read audio URL"
j -> orch: "urls = [ audio ]"
orch -> http: "player.play(audio)"
http -> cdn: "GET mp3 (range) → symphonia decodes"
```
## Boundaries / interfaces
- **Inbound**: `ProviderClient` (crabidy-core) — the orchestrator dispatches
`/jamendo/...` paths here. No new trait methods; search-term semantics reuse
`create_lib_node` / `rename_lib_node` / `delete_lib_node`.
- **Outbound**: the `Jam` trait (network seam) — the only place `reqwest` and
the `client_id` live. Everything above it is pure and unit-tested.
- **Config**: `jamendo.toml` (`client_id`, optional bounds), round-tripped via
`settings()`; wired through `crabidy-server` settings like every provider.
## Risks and open questions
- **R1 — `audio` URL plays on the windowed-HTTP path.** The whole
"no player change" claim rests on the `audio` MP3 streaming cleanly (range
requests, clean EOS, correct duration from metadata). **Live-test gate**: play
a Jamendo track end-to-end and confirm no panic, correct seek bar, clean EOS.
If a signed URL turns out non-range or short-lived, the fallback is the same
as `/youtube`: resolve-just-before-play and treat a stale URL as a skipped
track.
- **R2 — `audioformat` availability.** `mp32` may not exist for every track;
decode defensively and fall back to whatever `audio` the listing returned
(the `audio` field already reflects the requested format or the default).
- **R3 — field / envelope drift.** DTOs decode defensively
(`#[serde(default)]`, ids as strings); a renamed field is a local fix in
`JamApi`. Live validation is a task-plan gate.
- **R4 — `client_id` validity at startup.** We do not verify the key at init
(no blocking network in `build()`); the first browse reveals a bad key as a
typed `FetchError`. Acceptable — matches how the other remote providers fail
lazily rather than at boot.

242
architecture/orphans.md Normal file
View File

@ -0,0 +1,242 @@
# The `orphans` provider — a store garbage-collection view
## Context and problem statement
The content-addressed store (`architecture/crabidy-store.md`) never shrinks on
its own. Capturing writes audio + a `<name>.cbd-store.toml` sidecar into
`~/.local/share/crabidy/`; deleting a save (or the `current` queue rolling over,
or a `scan --capture` toml being removed) only ever removes the *toml that
pointed at* a store entry — never the entry itself (store `D7`). That is
deliberate: a store entry may be shared by many tomls, so no single deletion can
know it is safe to reclaim. The consequence, called out as future work in store
`D10`, is that store entries accumulate that **no toml references any more**.
There is today no way to see them or reclaim their disk.
This design adds a read-mostly management provider, **`/orphans`**, that surfaces
exactly those unreferenced store entries and lets the user rename or delete them,
or queue them for a listen before deciding. It is the store's garbage-collection
UI, expressed as an ordinary library subtree so it needs no new client concepts.
This **realizes** store `D10` (orphan reclamation). It changes nothing about how
captures are written or de-duplicated; it only *reads* the store's residue and
offers targeted rename/delete.
## Assumptions
- **Confirmed by the request.** `/orphans` lists every store item, walks all
local file providers, crosses off referenced items, and presents the rest;
entries are renamable (audio file *and* sidecar), deletable (files on disk),
and queueable.
- **"Referenced" means reachable through a mounted local file provider.** The
reference scan walks the disk roots of the running file providers — the
`/crabidy` toml tree (`~/.local/state/crabidy/`, which holds `current`, every
save, every capture) and the `/fs` root (which can hold `Playable::Store`
tomls written by `scan --capture`/`--move`). A `.cbd-track.toml` that lives
*outside* every mounted provider root (e.g. `scan --capture` run on a folder
that is not under `/fs`) is invisible to the walk, so its target counts as an
orphan. This is the only sound definition available without a global
reference index, and it matches the request's wording ("walks all local file
providers"). It is a documented boundary, not a bug (see Risks).
- **Single writer.** As with the rest of the store, one process owns both roots
and serializes mutations under the store's index mutex; there is no concurrent
external writer.
- **Orphan-ness is recomputed on every listing.** There is no persisted orphan
list — consistent with `fsdy`'s "read the tree fresh every visit" philosophy.
A capture that adds a reference makes an entry stop being an orphan on the
next listing.
## What is a "store item" and when is it an orphan
- A **store item** is a pair in `store_root`: an audio file `<name>` and its
sidecar `<name>.cbd-store.toml`. The sidecar is the source of truth (store
`D2`); the set of items is the set of sidecars that have a readable audio file
beside them. (A sidecar without audio, or audio without a sidecar, is
malformed residue — reported so it can be reclaimed too; see D4.)
- A store item `<name>` is **referenced** iff some `.cbd-track.toml` under a
mounted file-provider root validates to `Playable::Store(<name>)`.
- **Orphans = all store items referenced store items.**
Because `/orphans` only ever exposes unreferenced entries, renaming or deleting
one **cannot break any toml reference** — that is what makes the destructive
operations safe by construction (subject to the narrow race in Risks).
## Options considered
### Presentation: tracks vs. child nodes
Each orphan must be renamable, deletable, and queueable. The wire has two
carriers with capability flags:
- **`Track`** — has `is_captured`/`is_skipped` but **no** `is_editable`. Tracks
can be deleted (`tracks_deletable`) and queued, but the library has **no
rename-a-track gesture** anywhere; adding one means new proto surface plus TUI
and web changes.
- **`LibraryNodeChild`** — already carries `is_editable`, `is_deletable`,
`is_queable`, `is_captured`. The node-editing feature already binds `e`
`rename_lib_node(child_path, new_title)` and `d``delete_lib_node(child_path)`
for children that advertise the flags (that is how `/tidal/search` terms and
`/crabidy` saves are renamed/deleted today), and queueing a queueable child
resolves its tracks.
**Decision: present each orphan as an editable + deletable + queueable child
node** of `/orphans`, titled by its store file `<name>` (the thing a rename
edits). Entering the node lists its single track (the store audio, with metadata
from the sidecar); queueing the node — or the whole `/orphans` root — resolves
that track. This reuses `rename_lib_node`/`delete_lib_node`/`resolve_tracks_into`
and every client gesture **with zero proto, TUI, or web changes**. The only
minor wart — each orphan is a one-track "folder" — is acceptable and is exactly
how a single-track save already presents. The track-carrier option was rejected
purely on cost: it buys nothing the node model lacks and forces a wire change
just to gain a rename gesture.
### Home of the logic: new crate vs. server module
The orphan computation needs the store root and index (to enumerate items and
mutate them) *and* the file-provider disk roots (to find references). A standalone
`orphandy` crate would have to duplicate `CrabidyStore` internals it does not own.
**Decision: keep it in `crabidy-server`.** Orphan enumeration, rename, and delete
become methods on `CrabidyStore` (it already owns `store_root` and the index). A
thin new `OrphansProvider` (`crabidy-server/src/orphans.rs`) implements
`ProviderClient`, holding `Arc<CrabidyStore>` plus the list of reference roots to
walk, and delegates to those store methods. `ProviderOrchestrator` mounts and
routes `/orphans` exactly like the other providers.
## Boundaries and interfaces
```d2
direction: right
tui: TUI / web / cbd-cli { shape: person }
orchestrator: ProviderOrchestrator {
routes: "routes /orphans/*"
}
orphans: OrphansProvider {
refroots: "ref_roots: Vec<PathBuf>"
}
store: CrabidyStore {
index: "StoreIndex (by_hash / by_provider_id)"
ops: "list_orphans / rename_orphan / delete_orphan"
}
data: content store\n~/.local/share/crabidy {
shape: cylinder
items: "<name> + <name>.cbd-store.toml"
}
crabidytree: /crabidy tree\n~/.local/state/crabidy { shape: cylinder }
fstree: /fs root { shape: cylinder }
tui -> orchestrator: "get / rename / delete / queue /orphans/*"
orchestrator -> orphans: delegate
orphans -> store: "enumerate + mutate (by name)"
store -> data: "read sidecars, rename/delete files"
orphans -> crabidytree: "walk for Playable::Store refs"
orphans -> fstree: "walk for Playable::Store refs"
```
### The orphan diff (what a listing computes)
```d2
direction: down
allitems: "all store items\n(scan *.cbd-store.toml in store_root)"
refs: "referenced set\n(walk ref_roots for\nPlayable::Store(name))"
diff: "orphans = all referenced" { shape: diamond }
node: "/orphans node:\none editable/deletable/queueable\nchild per orphan"
allitems -> diff
refs -> diff
diff -> node
```
### Provider surface (`OrphansProvider: ProviderClient`)
Mounted at `/orphans` only when `crabidy_store` is present (it is the store's
view). Paths: the root `/orphans`, and one child per orphan at
`/orphans/<encode_segment(name)>`. There are no deeper levels.
- `get_lib_root` / `get_lib_node("/orphans")` — a queueable, non-creatable node
whose children are the current orphans (recomputed by the diff above). Each
child: `title = <name>`, `is_queable = true`, `is_editable = true`,
`is_deletable = true`, `is_downloadable = false`, `is_captured = true`.
- `get_lib_node("/orphans/<seg>")` — a queueable, childless node carrying the
single `Track` for that store entry (metadata from the sidecar's first
provider entry; `is_captured = true`). Unknown/renamed-away segment →
`MalformedPath`.
- `is_track_path` — always `false`: orphans are addressed as nodes, and the one
track is reached by resolving the node (so the default `resolve_tracks_into`
walk queues it). `get_metadata_for_track` therefore is not the entry point;
`get_urls_for_track("/orphans/<seg>")` returns `store_root/<name>` (a local
file path, exactly like a resolved `Playable::Store`) so the resolved track
still plays.
- `rename_lib_node("/orphans/<seg>", new)` — validates `new` as a bare store
file name (`validate_folder_name(new, &[])`: non-empty, no separators/NUL, no
leading dot), refuses a name already taken by another store entry
(`InvalidInput`), then renames **both** `<old>`→`<new>` audio and
`<old>.cbd-store.toml`→`<new>.cbd-store.toml`, and updates the in-memory index
(drop the old name's mappings, re-insert under the new name; hash and provider
ids are unchanged). Returns the renamed node at `/orphans/<encode(new)>`.
- `delete_lib_node("/orphans/<seg>")` — removes the audio file and the sidecar
from `store_root` and drops the entry from the index; returns the refreshed
`/orphans` root. Idempotent (an already-gone entry succeeds).
- `create_lib_node``NotSupported` (the root is not creatable).
### Store methods added to `CrabidyStore`
- `list_orphans(&self, ref_roots: &[PathBuf]) -> Result<Vec<OrphanEntry>, StoreError>`
— scan `store_root` for `*.cbd-store.toml`; build the referenced set by walking
each `ref_root` recursively for `*.cbd-track.toml` and collecting
`Playable::Store(name)`; return the difference as `OrphanEntry { name, title,
artist, duration, album }` (metadata from the sidecar's first provider entry).
- `orphan_track(&self, name) -> Result<Track, StoreError>` /
`orphan_url(&self, name) -> Result<String, StoreError>` — build the wire track
/ resolve the store audio path for a single entry.
- `rename_orphan(&self, old, new)` / `delete_orphan(&self, name)` — the mutations
above, under the index mutex, with the index kept in sync.
- `StoreIndex::remove(&mut self, name, sidecar)` — the inverse of `insert`, so
rename/delete can update the derived index without a full rescan.
### Reference roots wiring
`OrphansProvider` is constructed in `ProviderOrchestrator::init` with
`ref_roots` = the disk roots of the mounted file providers: the `/crabidy` tree
(`store.tree_dir()`) and, when enabled, the `/fs` root. A new
`fsdy::Client::disk_root(&self) -> &Path` accessor exposes the `/fs` root (the
`/crabidy` tree root is already available via `CrabidyStore::tree_dir`). If more
`fsdy` instances are ever mounted, they are added to this list — the definition
of "local file provider" is "an `fsdy` instance whose root can hold store
references."
## Risks and open questions
- **Capture-then-delete race (TOCTOU).** Between a `/orphans` listing and a
delete, a concurrent `W` capture could hash-hit the very entry the user is
about to delete and write a fresh `Playable::Store` reference to it; deleting
then leaves that new toml dangling. The window is small (store mutations
serialize under the index mutex and orphan-ness is recomputed every listing),
and the failure is benign: a dangling store reference already resolves to
`MalformedPath` at play time and is skipped, not a crash. Accepted; noted here
rather than engineered away.
- **References outside mounted roots are not counted.** As stated in Assumptions,
a `scan --capture` toml under a folder that is not mounted under `/fs` will not
be seen, so its target shows as an orphan. Deleting it would orphan that
toml's audio. The mitigation is scope discipline (scan under `/fs`); the
alternative — a persisted global reference index — is out of scope and would
fight the "read fresh" design.
- **Cost.** A `/orphans` listing scans the whole store plus both trees on every
visit (no cache), i.e. O(store entries + tomls under the roots). This matches
`fsdy`'s existing per-visit read cost and is fine for personal-library sizes;
if it ever bites, memoizing behind the index's mutation counter is the escape
hatch. Not premature-optimized here.
- **Malformed residue.** A sidecar with no audio (or vice versa) is itself
reclaimable junk. `list_orphans` reports such half-entries as orphans (titled
by whatever is present) so a delete cleans them up; it never treats a
half-entry as "referenced."
- **Open question:** should the `/orphans` root also expose a single "delete all"
affordance? Deferred — per-item delete covers the request; bulk reclaim can be
a later addition (a client could multi-select and delete, once marks exist
there).

View File

@ -130,7 +130,8 @@ Options considered:
Other provider commands keep flowing while (possibly several) resolves run. Other provider commands keep flowing while (possibly several) resolves run.
- **Playback side**: each queue op registers a *pending op* (id from an - **Playback side**: each queue op registers a *pending op* (id from an
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that `AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
drives `ProviderCommand::ResolveTracks` per path (sequentially, preserving drives `ProviderCommand::ResolveTracks` per path (concurrently, under the
exponential read-ahead window of D8, but forwarding chunks in strict
multi-path order) and forwards each chunk to the playback channel as multi-path order) and forwards each chunk to the playback channel as
`PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by `PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by
`ResolveFinished { op_id }`. Queue state is only ever mutated inside the `ResolveFinished { op_id }`. Queue state is only ever mutated inside the
@ -155,7 +156,21 @@ Each pending op keeps an insertion cursor:
Playback start reuses the existing `Option<Track>` returns from the Playback start reuses the existing `Option<Track>` returns from the
`QueueManager` mutations — only a chunk that makes a track current (replace, `QueueManager` mutations — only a chunk that makes a track current (replace,
or any insert into an empty queue) yields one, so exactly the first relevant or any insert into an empty queue) yields one, so exactly the first relevant
chunk starts the player and later chunks never restart it. chunk starts the player and later chunks never restart a playing track.
That first start can *fail to find anything playable yet*: if the head
tracks are `is_skipped` or the collection's first track is very short, the
player can run dry before the next chunk resolves — and then, since later
chunks return `None`, playback would stay stopped with playable tracks
arriving right behind it. So the op carries a `wants_start` flag: set when a
chunk makes a track current, cleared only once a start is *confirmed*
(`play` returned that it handed a track to the player). While it is set, each
arriving chunk retries the start from the current position — `next_playable`
advances past skipped/unplayable heads to the first track that has now
resolved. The exponential read-ahead (D8) makes that next track arrive
sooner; the retry makes sure it actually plays when it does. An op whose
whole resolve finishes with nothing playable is dropped by `finish_resolve`
and the player simply stays stopped.
Interleaved edits from other clients during a resolve can shift the cursor's Interleaved edits from other clients during a resolve can shift the cursor's
target (e.g. removing tracks before it). This is accepted as benign: target (e.g. removing tracks before it). This is accepted as benign:
@ -193,6 +208,41 @@ appended at render time only and never enters `self.list`, so selection,
removal, and `get_size` cannot reach it — no new input states, nothing to removal, and `get_size` cannot reach it — no new input states, nothing to
misclick. misclick.
### D8 — Exponential read-ahead across paths
The forwarder of D4 first resolved paths one at a time. That starts the first
track quickly (good) but fills the rest only as fast as one provider resolve
at a time. When enumeration is slow (per-path Tidal/YouTube/fyyd round trips)
and the leading tracks are very short or `is_skipped`, playback drains the
resolved queue faster than a sequential resolver refills it — and stalls into
silence, the exact thing progressive queueing exists to avoid.
Options considered:
1. **Sequential per path** (original): simplest, but a short/skipped head can
outrun a slow resolver.
2. **Resolve every path at once**: fills fastest, but a large marked
selection fires an unbounded burst of concurrent provider calls (rate
limits, memory) the instant playback starts — wasteful when the user skips
away after two tracks.
3. **Exponential read-ahead window**: a concurrency window that starts at 1
and doubles (1, 2, 4, 8, 16, then steady 16) after each path completes.
**Decision: (3).** The first path resolves alone, so time-to-first-track is
unchanged from (1); the window then grows geometrically, so the resolved
queue runs exponentially ahead of linear playback and a short/skipped head
cannot catch it after the first couple of tracks. The cap (16) bounds
concurrent provider load. Chunks are still forwarded in strict path order —
the forwarder fully drains the oldest in-flight resolve before the next, so
concurrency never reorders the queue (D5's per-op cursor and "first chunk
starts the player" are untouched). Cancellation (D6) drops the in-flight
receivers, stopping every concurrent resolve at once.
This is a read-ahead over *paths*. A single collection path (one album or
playlist) is still enumerated by its provider's `resolve_tracks_into` — the
per-page streaming of D3 is that path's read-ahead — so the window is the win
for multi-item selections; single-collection latency stays a provider concern.
## Flows ## Flows
```d2 ```d2

View File

@ -21,10 +21,26 @@ Three roles, credentialed by password hashes in the server config:
acceptable on a trusted home network; anything else (Internet acceptable on a trusted home network; anything else (Internet
exposure) needs TLS termination in front (reverse proxy, VPN) and is exposure) needs TLS termination in front (reverse proxy, VPN) and is
out of scope. The README says so. out of scope. The README says so.
- No auth configured (no `[auth]` section, or no hashes in it) means - **Anonymous callers inherit the highest *unguarded* role.** A request
the server behaves exactly as before: open, everyone is owner. Auth with no credentials is not rejected outright; it is granted the most
switches on as soon as **any** role hash is configured; from then on privileged role whose password is *not* set. So the config guards
every RPC requires credentials. from the top down and each password lowers what anonymous users can
do:
- nothing guarded → anonymous is **owner** (today's open server);
- `owner` guarded → anonymous is **queue-owner**;
- `owner` + `queue_owner` guarded → anonymous is **queue-appender**;
- all three guarded → anonymous can do **nothing** (`UNAUTHENTICATED`).
A credential still elevates a caller to its role; the anonymous role
is only the floor. A *present-but-wrong* credential is denied, never
silently downgraded to the anonymous role.
- **Guarding order is enforced.** Because anonymous callers get the
highest unguarded role, guarding a lower role while a higher one is
open is meaningless — the anonymous role would still outrank it. The
valid guarded sets are therefore prefixes of `[owner, queue_owner,
queue_appender]`. A config that sets `queue_owner` without `owner`
(or `queue_appender` without `queue_owner`) is **broken and aborts
startup**, fail-closed; `crabidy-server guard` likewise refuses to
write such a config.
- One password per role, not per person. The Basic-auth *username* - One password per role, not per person. The Basic-auth *username*
selects the role (`owner`, `queue-owner`, `queue-appender`), the selects the role (`owner`, `queue-owner`, `queue-appender`), the
password is verified against that role's hash. password is verified against that role's hash.
@ -88,9 +104,10 @@ Minimum role per RPC; higher roles include lower ones
layer, so renames/deletes stay owner-only, fail-closed). layer, so renames/deletes stay owner-only, fail-closed).
- **unknown / future methods**: owner only. - **unknown / future methods**: owner only.
Denied requests get `PERMISSION_DENIED`; missing or wrong credentials A caller whose role (anonymous or credentialed) is below the method's
get `UNAUTHENTICATED`. Credentials are never logged (hard rule: minimum gets `PERMISSION_DENIED`; a present-but-wrong credential, and
secrets redacted). an anonymous request to a fully-locked server, get `UNAUTHENTICATED`.
Credentials are never logged (hard rule: secrets redacted).
## Configuration ## Configuration
@ -99,8 +116,12 @@ both standalone and inside `cbd`; absent file = auth off):
```toml ```toml
[auth] [auth]
# One PHC hash per role; omit a role to disable it. # One PHC hash per role. Guard from the top down: setting a role's hash
# Generate with: crabidy-server hash-password # lowers what an anonymous (no-credential) caller may do to the next
# role below. Omitting a role leaves it open, so anonymous callers get
# the highest omitted role. Setting a lower role without the higher one
# (e.g. queue_owner without owner) is rejected at startup.
# Generate with: crabidy-server guard <role>
owner = "$argon2id$v=19$m=19456,t=2,p=1$..." owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..." queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..." queue_appender = "$argon2id$v=19$..."
@ -121,6 +142,18 @@ attaches `authorization: Basic …` to every request through a tonic
interceptor; without configured credentials it sends no header, which interceptor; without configured credentials it sends no header, which
keeps today's zero-config local setup working against an open server. keeps today's zero-config local setup working against an open server.
The web client stores its credentials in `localStorage` and attaches
them the same way. Because an anonymous browser silently connects as the
fallback role, it would otherwise never learn a login is possible, so
the `Init` response (reachable anonymously) carries an `auth_enabled`
flag: on first connect with no stored credentials against an
auth-enabled server, the client raises a login dialog. That dialog is
**dismissible** — "continue as guest" keeps the fallback role — so the
zero-typing browse path is preserved. When the server denies anonymous
access outright (all roles guarded → `UNAUTHENTICATED`), the same dialog
appears without the guest option, because credentials are then the only
way in.
## Structure ## Structure
```d2 ```d2

View File

@ -0,0 +1,302 @@
# soundcloud provider (streaming music)
## Context and problem statement
A new library provider mounted at `/soundcloud` that lets a user **search,
resolve share links, and play** SoundCloud tracks and playlists — and, **when
they opt in with a token, browse their own likes and playlists**.
- Like fyyd/tidal/youtube, SoundCloud is a **remote, search-driven** service.
Unlike them, SoundCloud offers **no official public API**: the modern
`api-v2.soundcloud.com` requires a `client_id` that SoundCloud embeds in its
web app and **rotates periodically**, and personal-account access needs an
**OAuth token**. So the provider must (a) obtain a `client_id` on its own and
survive rotation, and (b) treat login as **optional** — public browse + play
works with only a `client_id`; a token merely adds personal nodes.
- Content is organized as **tracks** and **playlists** (a playlist is a
container of tracks). There is no per-track container level like abs books —
the tree is `search-term → tracks`, `playlist → tracks`, and (logged in)
`likes → tracks` / `playlists → playlist → tracks`, plus a **resolve** entry
that turns a pasted permalink URL into a track or playlist.
- **Playing** a track is the biggest divergence from every existing provider.
SoundCloud does not serve a plain file URL: each track carries a set of
`media.transcodings`, and the playable ones are **HLS** — an `.m3u8` playlist
of short **mp3 segments**. crabidy's player streams a single byte source, so
this requires a new **HLS source** in `audio-player` that fetches the playlist
and streams the mp3 segments in order as one continuous mp3 (mp3 frames
byte-concatenate into a valid stream — the same fact `ffmpeg -c copy` relies
on). rodio's existing symphonia mp3 path then decodes it, unchanged.
- **Captures** (`W`, download) come for free once nodes serve tracks and raise
`is_downloadable`, exactly as for abs/fyyd — no wire or TUI work.
## The SoundCloud API (grounding)
Base `https://api-v2.soundcloud.com`. **Every** request carries `?client_id`
(plus `app_version`, `app_locale=en`), omitted from the cells below; personal
calls also send `Authorization: OAuth <token>`. `search/*` add
`&limit=<n>&offset=<o>&linked_partitioning=1`. Endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Resolve a permalink URL | `GET /resolve?url=<permalink>` |
| Search tracks | `GET /search/tracks?q=<t>` |
| Search playlists | `GET /search/playlists?q=<t>` |
| Track detail | `GET /tracks/<id>` |
| Playlist detail | `GET /playlists/<id>` |
| Transcoding → media URL | `GET <transcoding.url>``{"url": "<m3u8>"}` |
| (login) My likes | `GET /me/likes/tracks` (OAuth) |
| (login) My playlists | `GET /me/playlists` (OAuth) |
Objects (fields we read):
- **track**: `id` (numeric), `title`, `user.username` (→ artist), `duration`
(ms), `permalink_url`, `media.transcodings[]`, `policy`/`streamable`,
`publisher_metadata` (optional album/release).
- **transcoding**: `url` (a second API URL, not the CDN), `preset`,
`format.{protocol, mime_type}`, `quality`. We select
`protocol == "hls" && mime_type == "audio/mpeg"` (mp3-HLS), which SoundCloud
offers for essentially every playable track.
- **playlist**: `id`, `title`, `user.username`, `tracks[]` — often returned as
**stubs** (`{id}` only); missing tracks are hydrated in batches of ≤50 via
`GET /tracks?ids=<csv>&client_id=…`.
- **resolve**: returns a track or a playlist object (discriminated by `kind`).
`client_id` acquisition (no login): `GET https://soundcloud.com`, find the
referenced JS bundles, fetch them, regex `client_id:"(\w+)"`; `app_version`
from `window.__sc_version="(\d+)"`. This is exactly the streamrip approach.
## Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by `/youtube`, `/fyyd`, `/abs`): search-term + resolve semantics
cost no TUI or wire change. No proto change, no new `ProviderCommand`.
- A missing/rotated/invalid `client_id` must never crash startup or a browse.
The provider **self-heals** by scraping and by re-scraping on `401/403`; only
if scraping itself fails does the `/soundcloud` subtree degrade (typed
errors, skipped tracks), never the app.
- **Login is optional.** With no `oauth_token`, personal nodes (`likes`,
`playlists`) are simply **not shown**; public search/resolve/play still work.
A token unlocks the personal nodes and is refreshed/persisted like tidal's.
- HLS media/segment URLs and the `client_id`/`oauth_token` are **secrets or
ephemeral signed URLs**: redact from `Debug`/config dumps, never log the built
stream/segment URLs (hard rule: redact secrets from logs and error reports).
- SoundCloud `id`s are numeric (URL-safe); only user-typed **search terms** and
**pasted URLs** are percent-encoded into a path segment.
## Decisions
### D1 — Crate `soundclouddy`, mounted at `/soundcloud`, non-fatal init
New workspace crate `soundclouddy` implementing `ProviderClient`, shaped on
`fyyd`/`absdy` (remote, search-driven, plain leaf tracks). Wired into
`ProviderOrchestrator` with a `sc_client: Option<Arc<soundclouddy::Client>>`
field, `sc_owns()`/`sc_provider()` helpers, a `build()` block that reads
`soundcloud.toml` (non-fatal), a `get_lib_root` child gated on
`self.sc_client.is_some()`, and one routing arm in each dispatch method.
`crabidy-server` settings gain `soundcloud` in `ALL_PROVIDERS` (now 8), in
`ProviderToggles`, in `all()`, and in `provider_toggles()`. No `cli.rs`/
`main.rs` change (providers are pure path-prefix subtrees).
### D2 — HTTP behind a trait, faked in tests; client_id lifecycle inside the seam
All network access goes through one seam — an `Sc` trait (`resolve`,
`search_tracks`, `search_playlists`, `track_detail`, `playlist_detail`,
`hydrate_tracks`, `resolve_stream_url`, and, when logged in, `my_likes`,
`my_playlists`) behind `Box<dyn Sc>` — with a `reqwest`-based `ScApi` for
production and a `FakeApi` in tests (as `absdy` hides `reqwest` behind `Abs`).
The **client_id acquisition, caching, and re-scrape-on-401** live entirely
inside `ScApi` so provider logic (tree shaping, path parsing, term store) is
unit-tested with zero network. Errors map to `ProviderError::FetchError` at the
boundary; malformed paths → `MalformedPath`; empty create/rename → `InvalidInput`.
### D3 — Tree shape (search, resolve, optional personal)
Track ids and playlist ids are both numeric, so leaf/container paths use a
**type-tagged** canonical segment to disambiguate: `track/<id>` and
`playlist/<id>`. Browse nodes point their children at these canonical paths.
- `/soundcloud` — children: `search` (creatable), `resolve` (creatable), and —
**only if logged in**`likes` and `playlists`. Not itself queueable.
- `/soundcloud/search` / `/soundcloud/resolve``is_creatable`; children are
the in-memory terms/URLs (`RwLock<Vec<String>>`, dedup), each editable and
deletable, like tidal/youtube/fyyd/abs search terms.
- `/soundcloud/search/<term>` — matching **tracks** as queueable leaves (and,
optionally, matching playlists as containers).
- `/soundcloud/resolve/<url>` — the resolved permalink: a single track leaf, or
a playlist container.
- `/soundcloud/likes` (login) — the user's liked **tracks**.
- `/soundcloud/playlists` (login) — the user's playlists as containers.
- `/soundcloud/playlist/<id>` — a playlist's tracks (queueable, downloadable);
the canonical container path, reached from search/resolve/likes/playlists.
- `/soundcloud/track/<id>` — the canonical **track leaf**. A track id alone is
sufficient to resolve a stream, so every branch's track children point here
and playback needs no browse context.
Terms/URLs are percent-encoded into one segment (`encode_segment`/
`decode_segment`); numeric ids are already URL-safe.
### D4 — Playback: progressive mp3 (HLS source retained as fallback)
**Revised after live testing (2026-07-24).** The original plan chose HLS mp3,
but live probing showed SoundCloud's plain `hls + audio/mpeg` transcoding
exchange **404s for anonymous clients** (every track, streamable or not), while
the **`progressive + audio/mpeg`** transcoding returns 200 with a direct,
range-streamable mp3 URL (`cf-media.sndcdn.com`, `206`, `audio/mpeg`). So the
provider now **prefers progressive**, which the player streams on its normal
windowed-HTTP path — no HLS needed for the common case. The `HlsStream` built
for the original plan is kept as a fallback for any track that offers only HLS.
- `get_urls_for_track(/soundcloud/track/<id>)`: `GET /tracks/<id>`, pick the
best mp3 transcoding (`progressive` first, then `hls`), `GET
<transcoding.url>?client_id=…` → the media URL, and **return it as `urls[0]`**
(the player consumes only the first). One API round-trip — unlike abs's pure
string-building — because the media URL is signed and ephemeral. A track
whose exchange 404s (Go+/label preview, geo-blocked) surfaces
`NotStreamable`/`NotFound` and is skipped, never a crash.
- **New `audio-player` component `HlsStream`** — a `stream-download`
`SourceStream`, sibling to `WindowedHttpStream`: on create it fetches the
`.m3u8` (a media playlist), parses `#EXTINF`/segment URIs (resolving relative
URIs against the playlist URL, and following one level if handed a master
playlist); on poll it streams each mp3 segment's bytes in order, advancing at
segment boundaries, finishing after the last. The concatenated bytes are a
valid mp3 → rodio's symphonia mp3 decoder handles them unchanged.
- **Routing**: `open_source` selects `HlsStream` when the URL path ends in
`.m3u8` (SoundCloud's media URLs carry it); all other http URLs keep the
windowed-HTTP path, and content-sniffing (opus vs the rest) is unchanged
downstream. `#EXTM3U` content-sniff is a hardening fallback if needed.
- **Duration** comes from the track metadata (`duration` ms → `Track.duration`),
not from the stream, so the seek bar is correct even though the concatenated
HLS stream carries no container duration.
- Track fields: `title` = track title, `artist` = `user.username`, `album` from
`publisher_metadata` when present else `None`, `duration` = ms→`Option<u32>`,
`provider_item_id` = `"track:<id>"` (keys the capture store).
### D5 — Auth: client_id self-heal, optional OAuth login
- `Settings`: `client_id: Option<String>`, `app_version: Option<String>`
(both **cached** after first scrape and round-tripped via `settings()` so we
don't re-scrape every start), `oauth_token: Option<String>` (optional),
and bounds (D6). Hand-written `Debug` redacts `client_id`/`oauth_token`.
- **No login (baseline)**: if `client_id` is unset, `ScApi` scrapes it from
`soundcloud.com` at init; on any `401/403` it re-scrapes **once** and retries
(rotation recovery). The freshly scraped id is persisted.
- **Optional login**: if `oauth_token` is present, `get_lib_root` adds `likes`
and `playlists`, and personal calls send `Authorization: OAuth <token>`. If a
refresh-token flow is configured later it mirrors tidal's persist-on-refresh;
v1 accepts a static token and, on `401`, drops the personal subtree with a
typed error (never a crash) — public browse is unaffected.
### D6 — Bounds and freshness
- `search_results` (default 50), `playlist_tracks_limit` (default 500, hydrated
in ≤50-id batches), `call_timeout_secs` (default 30) bound each HTTP call, and
`hls_total_deadline_secs` (default 300) bounds a whole HLS fetch (segments are
retried with jitter under this deadline). Caps are `log`-ged so truncation is
visible, not silent (hard rule: no silent caps).
- Listings are fetched fresh per call (no cross-call cache), like the other
remote providers; only search terms / resolve URLs are stored in memory. The
chosen transcoding may be briefly cached per track id to save the extra
round-trip on replay.
### D7 — Out of scope (explicitly)
- **opus-HLS** (`audio/ogg; codecs=opus`) and **progressive** transcodings: v1
targets mp3-HLS uniformly (offered for ~all tracks). opus-HLS is a phase-2
add that reuses the new `OpusSource` per segment; the `HlsStream` seam makes
it additive.
- **HLS seeking**: v1 HLS is forward-only and reports the source as
non-seekable, so symphonia does not attempt an end-seek (which would need a
known byte length). In-track seek is a later add (open at a segment offset).
- Go+ / high-quality / lossless streams (need a premium account), uploads,
comments, reposts feed, waveforms, social graph, and playback-progress sync.
- Pagination past the configured caps (one page per listing).
## Structure
```d2
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
sc: "soundclouddy (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms + resolve URLs\n(in-memory)"
api: "ScApi\n(reqwest seam: Sc trait,\nclient_id self-heal, opt OAuth)"
client -> terms
client -> api
}
player: "audio-player" {
hls: "HlsStream\n(new SourceStream)"
dec: "rodio / symphonia\n(mp3, opus)"
hls -> dec: "concatenated mp3 bytes"
}
scapi: "SoundCloud\napi-v2 + HLS CDN" { shape: cloud }
web: "soundcloud.com\n(HTML + JS)" { shape: cloud }
server.orch -> sc.client: "/soundcloud/..."
sc.api -> scapi: "resolve / search / tracks / transcoding (JSON, timeout)"
sc.api -> web: "scrape client_id (init, on 401)"
server.orch -> player.hls: ".m3u8 media URL"
player.hls -> scapi: "GET m3u8 + mp3 segments"
```
## Key flow: search and play a track
```d2
shape: sequence_diagram
tui: TUI
orch: Orchestrator
s: soundclouddy
api: "SoundCloud api-v2"
hls: "HlsStream (audio-player)"
cdn: "HLS CDN"
tui -> orch: "open /soundcloud/search"
tui -> orch: "create term \"boards of canada\""
orch -> s: "create_lib_node(search, term)"
s -> tui: "term stored"
tui -> orch: "open /soundcloud/search/<term>"
orch -> s: "get_lib_node"
s -> api: "GET /search/tracks?q=…&client_id="
api -> s: "tracks (id, title, user, duration)"
s -> tui: "tracks as queueable leaves (/soundcloud/track/<id>)"
tui -> orch: "queue + play a track"
orch -> s: "get_urls_for_track(/soundcloud/track/<id>)"
s -> api: "GET /tracks/<id> → pick hls+audio/mpeg transcoding"
s -> api: "GET <transcoding.url>?client_id= → { url: m3u8 }"
s -> orch: "urls = [ m3u8 ]"
orch -> hls: "player.play(m3u8)"
hls -> cdn: "GET m3u8 (segments)"
hls -> cdn: "GET segment 1..N (mp3, in order)"
hls -> orch: "continuous mp3 → symphonia decodes"
```
## Risks and open questions
- **client_id scraping fragility.** The scrape regexes depend on
soundcloud.com's HTML/JS shape and can break on a redesign. Mitigation: a
config override (`client_id` in `soundcloud.toml`) always wins, and failures
are typed (subtree degrades, app survives). The scrape is the one piece with
no test-double coverage of the *live* format — flagged as a **live-test gate**.
- **Ephemeral media URLs.** The resolved `.m3u8` and its segments are signed and
short-lived; playback must start promptly after resolution (like `/youtube`).
A stale URL surfaces as a skipped track, never a crash. Never logged.
- **HLS without a known length.** The concatenated stream has no total byte
length; reported non-seekable so symphonia won't end-seek (the rodio-0.22
panic the opus work documented). **Gate:** verify an end-to-end mp3-HLS play
does not panic and reaches EOS cleanly.
- **Master vs media playlist.** SoundCloud returns a media (segment) playlist
for the chosen transcoding; `HlsStream` follows one level of master playlist
defensively and picks the first variant.
- **Playlist stubs.** Playlist detail may return track stubs; hydration in
≤50-id batches is bounded by `playlist_tracks_limit`. A hydration miss drops
that track (skipped), never an error.
- **OAuth token lifetime.** v1 accepts a static token; expiry drops the personal
subtree with a typed `401` (public browse unaffected). A device-flow/refresh
upgrade mirrors tidal and is additive.
- **Field / envelope drift.** DTOs decode defensively (`#[serde(default)]`);
a renamed field is a local fix in `ScApi`. Live validation is a task-plan gate.

157
architecture/visual-mode.md Normal file
View File

@ -0,0 +1,157 @@
# TUI visual mode (paint-select with movement)
## Context and problem statement
The library pane supports **marks**: `s` toggles the selected row's mark (gated
on `is_queable`), and the multi-item actions (`a` append, `L` queue-next, `Enter`
replace, `w`/`W` capture… — via `get_selected`) operate on the marked set,
falling back to the bare selection. Marking a contiguous run today means pressing
`s`, moving, `s`, moving, `s`… — one keystroke per row.
The request: a vim-style **visual mode**. Press `v` (and `V` — both do the same)
to enter it; then **movement toggles the mark of the rows it sweeps over**, so a
run is selected by `v` then `j j j` (or `v G`). Pressing `v`/`V` again — or `Esc`
— leaves visual mode; the marks persist for the next action.
This frees no key, so the **spectrum toggle currently on `v` must move**
(architecture/spectrum.md; it is a client-only display toggle).
## Assumptions (decided here)
- **Library-only.** Marks exist only in the library pane; the queue's marks are
a standing `FIXME` (`queue.rs`), so visual mode binds in the **Library scope**
only. Queue visual mode is out of scope until the queue grows marks (D5).
- **Marks are the selection.** Visual mode is pure UI over the existing
`UiItem.marked` — no new wire types, no server calls, no proto change. It only
changes *how* marks get toggled.
- **`is_queable` gating is preserved.** `toggle_mark` only marks queueable rows;
paint-toggle does the same, so sweeping over a non-queueable row leaves it
unmarked (consistent with `s`).
- TUI-only, like `tui-search`. Web-client parity is a follow-up (D6).
## Decisions
### D1 — `v`/`V` enter visual mode; spectrum moves to `f`
Two new Library-scoped bindings, `v` (`NONE`) and `V` (`SHIFT`), both mapping to
one new `Action::LibraryVisualMode` (same two-binding/one-action pattern as
`K`/`J`, `W`, etc.). `Action::ToggleSpectrum` moves from Global `v` to **Global
`f`** ("frequency"), a key free in every scope. *This letter is a pure
preference — trivially changed in `bindings.rs`; `f` is the chosen default.*
### D2 — Paint-toggle semantics: sweep toggles, endpoints included
Visual mode holds one piece of state — that it is **active** (the anchor is
the **anchor** — the view index where `v` was pressed — plus the cursor).
Behavior:
- **Enter** (`v`/`V` while normal): activate, **anchor at the current row**, and
**toggle its mark** (vim includes the row you start on). A lone `v … v` thus
behaves like a single `s`.
- **Move** (any of `j`/`k`, `g`/`G`, `Ctrl-d`/`Ctrl-u` while active): perform the
move, then reconcile marks to the contiguous **range `[anchor, cursor]`**
toggle exactly the rows whose membership in that range **changed** (relative to
the anchor). Growing the range marks the rows entered; **shrinking it unmarks
the rows left**, so moving back down/up **cleanly reverses** a move and the row
you turn around on is never stranded. Jumps (`G`, `Ctrl-d`) reconcile the whole
span at once. It is a *toggle* against range membership, so sweeping over a
row that was already marked (by `s`) flips it, and sweeping back flips it back.
- **Exit**: `v`/`V` again, or `Esc`, deactivates (marks persist). Any other
action key (`a`, `Enter`, `w`, …) also **exits first, then runs normally**, so
`v j j a` selects three rows and appends them. Changing node (`h`/`l`) or focus
(`Tab`) also exits — the anchor/indices would otherwise be stale.
```d2
direction: right
shape: sequence_diagram
Normal
Visual
Normal -> Visual: "v / V (anchor here, toggle current row)"
Visual -> Visual: "j k g G C-d C-u (reconcile marks to [anchor, cursor])"
Visual -> Normal: "v / V / Esc (marks kept)"
Visual -> Normal: "a / Enter / w / … (exit, then act on marks)"
Visual -> Normal: "h / l / Tab (node/focus change)"
```
Worked example (rows `0..9`, all unmarked, cursor at `0` = anchor):
```text
v -> {0} anchor 0, toggle current
j -> {0,1} range [0,1]
j -> {0,1,2} range [0,2]
G -> {0..9} range [0,9] (jump reconciles the span)
k -> {0..8} range [0,8] -> 9 leaves, unmarked
g -> {0} range [0,0] -> 1..8 leave, unmarked
v -> exit, marks {0} kept
```
### D3 — Where the state lives and how dispatch routes it
`Library` owns the visual state as `visual: Option<usize>``Some(anchor_view)`
while active, so the mode and its anchor are one field, exposed as `is_visual`,
`toggle_visual`, `exit_visual`. `App::dispatch` is the single choke point (it
already maps every `Action`):
- `LibraryVisualMode``library.toggle_visual()` (activate: anchor at the cursor
and toggle its mark; or deactivate). Only reachable while the library is
focused (Library-scope binding).
- The six movement actions → a `library_move` helper: when visual is active, read
the cursor, run the existing move, read the cursor again, and call
`Library::paint_between(old_view, new_view)`; otherwise just move.
- `LibraryAscend`/`LibraryDive`/`CycleFocus` → covered by the catch-all below
(they exit visual, then proceed).
- `ClearSearch` (`Esc`) → if visual is active, just leave it (do not also clear
the search filter); else unchanged.
- Every other action → leave visual mode first, then proceed. Implemented as a
guard at the top of `dispatch`: capture `was_visual`, and exit unless the
action is one of the six movements, `LibraryVisualMode`, or `ClearSearch`.
`Library` gains `selected_view()` (the current view index) and
`paint_between(from_view, to_view)` — using the stored `anchor`, toggle the mark
(respecting `is_queable`) of every view index whose membership in `[anchor,
cursor]` changed between the old and new cursor, mapping each through the `/`
filter to its real index exactly as `toggle_mark` does. No change to `select`, so
non-visual selection (filter re-select, `update_selection`) never paints.
### D4 — Visual indicator
The library pane title shows `— VISUAL` while active (same title slot as the
`— /query▏` search hint and `— % to add`). The help modal lists `v`/`V`
("Enter visual mode: movement toggles marks") in the Library group and the moved
`f` spectrum toggle in the Global group — both derived from `BINDINGS`, so they
stay correct for free.
### D5 — Out of scope: queue visual mode
The queue has no marks, so `v`/`V` are unbound there (a no-op). Extending visual
mode to the queue is gated on giving the queue a mark set (the existing
`queue.rs` `FIXME`) and is left for that work.
### D6 — Out of scope: web-client parity
`cbd-web` mirrors the TUI keymap; a visual mode there is a clean follow-up (its
`state.rs`/`keymap.rs` are the analog seams), not part of this TUI change.
## Boundaries / interfaces
- **`bindings.rs`** (pure data): `+LibraryVisualMode`, its two Library bindings,
and the `ToggleSpectrum` chord moves `v`→`f`. All dispatch/help/label logic is
already derived from the table.
- **`app/mod.rs`** (`App`): the `was_visual` guard + `library_move` + the
dispatch routing above.
- **`app/library.rs`** (`Library`): the `visual: Option<usize>` anchor,
`is_visual`/`toggle_visual`/`exit_visual`, `selected_view`, `paint_between`, and
the title indicator. Marks, filter, and `select` are reused unchanged.
## Risks and open questions
- **Anchor semantics chosen over per-step toggle.** An earlier half-open
per-step design stranded the turnaround row (down-then-up left the furthest row
marked). The anchored `[anchor, cursor]` range reconciliation fixes that:
moving back cleanly reverses. Sweeping over a pre-existing (`s`) mark still
*toggles* it against range membership — reversible, but worth knowing.
- **Filter interaction.** With a `/` filter active, paint sweeps **view** indices
and toggles their real rows, so only visible rows are affected — consistent
with how `s` and `get_selected` already treat marks vs. the filtered view.
- **Stale indices on list change.** Any node/focus change exits visual mode, so
a reload can never paint against a previous list.

View File

@ -161,7 +161,7 @@ keys -> ui
| all playback + volume/mute/shuffle/repeat | transport bar & keys | | all playback + volume/mute/shuffle/repeat | transport bar & keys |
| skipped tracks red | same, via `Track.is_skipped` | | skipped tracks red | same, via `Track.is_skipped` |
| update stream reconnect | same, backoff + disconnect banner | | update stream reconnect | same, backoff + disconnect banner |
| auth via config file | login form on `UNAUTHENTICATED`, localStorage | | auth via config file | login form (proactive + forced), localStorage |
| `?` help modal | `?` help overlay listing keys | | `?` help modal | `?` help overlay listing keys |
## Styling ## Styling

View File

@ -1,5 +1,5 @@
FROM ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge FROM ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge
RUN dpkg --add-architecture armhf RUN dpkg --add-architecture armhf
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y alsa:armhf librust-alsa-sys-dev:armhf libasound2-dev:armhf portaudio19-dev:armhf build-essential libpulse-dev:armhf libdbus-1-dev:armhf pkg-config apt-utils unzip RUN apt-get update && apt-get install -y alsa:armhf librust-alsa-sys-dev:armhf libasound2-dev:armhf portaudio19-dev:armhf build-essential cmake libpulse-dev:armhf libdbus-1-dev:armhf pkg-config apt-utils unzip
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip

View File

@ -3,6 +3,16 @@ name = "audio-player"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# The one axis worth a flag: Ogg-Opus decoding pulls symphonia *and*
# `symphonia-adapter-libopus`, which bundles libopus and so makes cmake +
# ninja build requirements. Everything else in this crate (HLS, the
# windowed-HTTP source, the spectrum tap) shares its dependencies with
# code that always ships, so gating it would buy nothing
# (architecture/build-features.md D6/D7/D8).
[features]
default = ["opus"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
@ -11,6 +21,8 @@ futures.workspace = true
reqwest.workspace = true reqwest.workspace = true
rodio.workspace = true rodio.workspace = true
stream-download.workspace = true stream-download.workspace = true
symphonia = { workspace = true, optional = true }
symphonia-adapter-libopus = { workspace = true, optional = true }
thiserror.workspace = true thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing.workspace = true tracing.workspace = true

318
audio-player/src/hls.rs Normal file
View File

@ -0,0 +1,318 @@
//! An HLS [`SourceStream`]: fetches an `.m3u8` media playlist and streams its
//! mp3 segments in order as one continuous byte stream.
//!
//! SoundCloud (and other HLS sources) serve audio as a playlist of short mp3
//! segments rather than one file. mp3 frames byte-concatenate into a valid
//! stream (the fact `ffmpeg -c copy` relies on), so streaming the segments in
//! order yields bytes rodio's symphonia mp3 decoder handles unchanged
//! (architecture/soundcloud-provider.md D4).
//!
//! Sibling to [`crate::windowed_http::WindowedHttpStream`]. This is **forward
//! only**: it reports the source as non-seekable and length-less, so symphonia
//! does not attempt an end-seek that would need a known total length (the
//! rodio-0.22 panic the opus work documented).
//!
//! Never logs the playlist or segment URLs — they are signed and ephemeral.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures::{Future, Stream};
use stream_download::source::{DecodeError, SourceStream};
use tracing::{debug, trace, warn};
use url::Url;
/// Parameters for [`HlsStream::create`].
#[derive(Clone, Debug)]
pub struct HlsParams {
/// The `.m3u8` media-playlist URL.
pub url: Url,
pub client: reqwest::Client,
}
impl HlsParams {
pub fn new(url: Url, client: reqwest::Client) -> Self {
Self { url, client }
}
}
/// Error creating the stream (playlist fetch/parse failed).
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct HlsError(String);
impl DecodeError for HlsError {}
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
type SegmentFuture = Pin<Box<dyn Future<Output = io::Result<BytesStream>> + Send + Sync>>;
/// A parsed playlist: either the media (segment) playlist we want, or a master
/// playlist pointing at variant playlists (we follow the first).
enum Playlist {
Media(Vec<Url>),
Master(Url),
}
enum State {
/// Ready to fetch `segments[cursor]`.
Idle,
/// Waiting for a segment response.
Requesting(SegmentFuture),
/// Draining a segment body.
Streaming(BytesStream),
Finished,
}
/// See the module docs. Streams `segments[cursor]` bytes, advancing at segment
/// boundaries; finishes after the last.
pub struct HlsStream {
client: reqwest::Client,
/// Ordered mp3 segment URLs parsed from the media playlist.
segments: Vec<Url>,
/// Index of the next segment to fetch.
cursor: usize,
state: State,
}
impl std::fmt::Debug for HlsStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HlsStream")
.field("segments", &self.segments.len())
.field("cursor", &self.cursor)
.finish_non_exhaustive()
}
}
impl HlsStream {
/// Schedules the fetch of the segment at `cursor`, or finishes when none
/// remain.
fn schedule_next(&mut self) {
let Some(url) = self.segments.get(self.cursor).cloned() else {
self.state = State::Finished;
return;
};
self.cursor += 1;
self.state = State::Requesting(Box::pin(fetch_segment(self.client.clone(), url)));
}
}
/// GETs one segment, returning its body byte-stream. A non-success status is an
/// error carrying the status only (never the signed URL).
async fn fetch_segment(client: reqwest::Client, url: Url) -> io::Result<BytesStream> {
trace!("fetching hls segment");
let resp = client.get(url).send().await.map_err(|err| {
io::Error::other(format!("segment request failed: {}", err.without_url()))
})?;
if !resp.status().is_success() {
return Err(io::Error::other(format!(
"segment request rejected: {}",
resp.status()
)));
}
Ok(Box::pin(resp.bytes_stream()))
}
impl Stream for HlsStream {
type Item = io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
loop {
match &mut this.state {
State::Finished => return Poll::Ready(None),
State::Idle => this.schedule_next(),
State::Requesting(future) => match future.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(stream)) => this.state = State::Streaming(stream),
Poll::Ready(Err(err)) => {
this.state = State::Finished;
return Poll::Ready(Some(Err(err)));
}
},
State::Streaming(stream) => match stream.as_mut().poll_next(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Some(Ok(bytes))) => return Poll::Ready(Some(Ok(bytes))),
Poll::Ready(Some(Err(err))) => {
return Poll::Ready(Some(Err(io::Error::other(format!(
"segment body failed: {}",
err.without_url()
)))));
}
// Segment drained: move to the next one.
Poll::Ready(None) => this.schedule_next(),
},
}
}
}
}
impl SourceStream for HlsStream {
type Params = HlsParams;
type StreamCreationError = HlsError;
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
let segments = load_segments(&params.client, params.url).await?;
debug!(segments = segments.len(), "hls stream open");
if segments.is_empty() {
return Err(HlsError("hls playlist has no segments".to_string()));
}
Ok(Self {
client: params.client,
segments,
cursor: 0,
state: State::Idle,
})
}
/// Unknown up front (segment sizes are not in the playlist), so `None` —
/// which, with `supports_seek() == false`, keeps symphonia off the
/// end-seek path.
fn content_length(&self) -> Option<u64> {
None
}
/// Forward-only. In-track seeking (open at a segment offset) is a later,
/// additive change (architecture/soundcloud-provider.md D7).
async fn seek_range(&mut self, _start: u64, _end: Option<u64>) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"HLS stream is forward-only",
))
}
/// Best-effort resume after a dropped connection: re-open the current
/// segment. `stream-download`'s temp storage already holds everything up to
/// `current_position`, and its `Read` side serves that; this only needs to
/// resume producing fresh bytes, so re-fetching the in-flight segment is
/// acceptable (a short overlap at worst).
async fn reconnect(&mut self, _current_position: u64) -> io::Result<()> {
let restart = self.cursor.saturating_sub(1);
warn!(
segment = restart,
"reconnecting hls stream at current segment"
);
self.cursor = restart;
self.state = State::Idle;
Ok(())
}
fn supports_seek(&self) -> bool {
false
}
}
/// Fetches the playlist at `url`, following one level of master playlist, and
/// returns the ordered media-segment URLs.
async fn load_segments(client: &reqwest::Client, url: Url) -> Result<Vec<Url>, HlsError> {
let body = fetch_text(client, url.clone()).await?;
match parse_playlist(&body, &url)? {
Playlist::Media(segments) => Ok(segments),
Playlist::Master(variant) => {
let body = fetch_text(client, variant.clone()).await?;
match parse_playlist(&body, &variant)? {
Playlist::Media(segments) => Ok(segments),
// A master pointing at another master is not something
// SoundCloud produces; refuse rather than recurse.
Playlist::Master(_) => Err(HlsError("nested master playlist".to_string())),
}
}
}
}
async fn fetch_text(client: &reqwest::Client, url: Url) -> Result<String, HlsError> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| HlsError(format!("playlist request failed: {}", e.without_url())))?;
if !resp.status().is_success() {
return Err(HlsError(format!("playlist rejected: {}", resp.status())));
}
resp.text()
.await
.map_err(|e| HlsError(format!("playlist read failed: {e}")))
}
/// Parses an m3u8 body. A master playlist (`#EXT-X-STREAM-INF`) yields the
/// first variant URI; otherwise every non-comment line is a media segment.
/// Relative URIs resolve against `base`.
fn parse_playlist(body: &str, base: &Url) -> Result<Playlist, HlsError> {
let is_master = body.contains("#EXT-X-STREAM-INF");
let mut uris = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let resolved = base
.join(line)
.map_err(|e| HlsError(format!("bad segment URI: {e}")))?;
uris.push(resolved);
if is_master {
// Only the first variant is needed.
break;
}
}
if is_master {
uris.into_iter()
.next()
.map(Playlist::Master)
.ok_or_else(|| HlsError("master playlist has no variant".to_string()))
} else {
Ok(Playlist::Media(uris))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Url {
Url::parse("https://cf-hls.sndcdn.com/media/0/playlist.m3u8?token=x").unwrap()
}
#[test]
fn parses_media_playlist_absolute_and_relative() {
let body = "#EXTM3U\n\
#EXT-X-VERSION:6\n\
#EXTINF:10.0,\n\
https://cdn.sndcdn.com/media/0/1.ts\n\
#EXTINF:9.9,\n\
2.ts\n\
#EXT-X-ENDLIST\n";
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
panic!("expected media playlist");
};
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].as_str(), "https://cdn.sndcdn.com/media/0/1.ts");
// Relative URI resolved against the playlist URL.
assert_eq!(segs[1].as_str(), "https://cf-hls.sndcdn.com/media/0/2.ts");
}
#[test]
fn follows_first_master_variant() {
let body = "#EXTM3U\n\
#EXT-X-STREAM-INF:BANDWIDTH=128000\n\
variant-128.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=64000\n\
variant-64.m3u8\n";
let Playlist::Master(url) = parse_playlist(body, &base()).unwrap() else {
panic!("expected master playlist");
};
assert_eq!(
url.as_str(),
"https://cf-hls.sndcdn.com/media/0/variant-128.m3u8"
);
}
#[test]
fn ignores_comments_and_blank_lines() {
let body = "#EXTM3U\n\n#EXTINF:1,\nonly.ts\n\n";
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
panic!("expected media playlist");
};
assert_eq!(segs.len(), 1);
}
}

View File

@ -1,8 +1,13 @@
mod hls;
// Ogg-Opus needs symphonia's Ogg demuxer plus the bundled libopus decoder, so
// it is the crate's one feature (architecture/build-features.md D6).
#[cfg(feature = "opus")]
mod opus_source;
mod player; mod player;
mod player_engine; mod player_engine;
mod spectrum_tap; mod spectrum_tap;
pub mod windowed_http; pub mod windowed_http;
pub use player::{Player, PlayerError}; pub use player::{Player, PlayerError};
pub use player_engine::{MediaInfo, PlayerMessage}; pub use player_engine::{output_device_names, MediaInfo, PlayerMessage};
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW}; pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};

View File

@ -0,0 +1,342 @@
//! Ogg-Opus decoding for the rodio pipeline.
//!
//! rodio decodes through symphonia, and symphonia 0.5 ships no Opus
//! decoder — so raw `.opus` streams (notably audiobookshelf files, but any
//! provider serving Opus) fail rodio's `Decoder::build()`. This module fills
//! that gap: it demuxes the container with symphonia's own Ogg reader and
//! decodes the Opus packets with libopus via [`symphonia_adapter_libopus`],
//! then exposes the result as a rodio [`Source`] so the rest of the player
//! (sink, spectrum tap, seeking) is unchanged.
//!
//! Only Ogg-*Opus* is handled here; every other format (including Ogg-Vorbis
//! and Ogg-FLAC, which symphonia decodes natively) stays on rodio's decoder.
//! [`is_ogg_opus`] does the sniffing and the player routes accordingly.
//!
//! The decode loop, buffering, and seek refinement mirror rodio 0.22's own
//! `SymphoniaDecoder`; the only substantive difference is that we build the
//! format reader and the codec from an explicit registry that includes the
//! libopus adapter, instead of symphonia's default `get_codecs()`.
use std::io::{Read, Result as IoResult, Seek, SeekFrom};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use symphonia::core::audio::{AudioBufferRef, SampleBuffer, SignalSpec};
use symphonia::core::codecs::{
CodecRegistry, Decoder as SymphoniaDecoderTrait, DecoderOptions, CODEC_TYPE_OPUS,
};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, SeekedTo};
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
use symphonia::core::units;
use symphonia::default::formats::OggReader;
use symphonia_adapter_libopus::OpusDecoder;
use rodio::source::SeekError;
use rodio::{ChannelCount, Sample, SampleRate, Source};
/// The Ogg page capture pattern (every page starts with it).
const OGG_MAGIC: &[u8] = b"OggS";
/// The magic signature of the Opus identification header packet, which is
/// the first packet of an Ogg-Opus stream (RFC 7845 §5.1).
const OPUS_HEAD_MAGIC: &[u8] = b"OpusHead";
/// Returns true if `header` looks like the start of an Ogg-Opus stream: an
/// Ogg page whose first packet is the Opus identification header. `header`
/// should be at least the first Ogg page (~64 bytes is plenty; `OpusHead`
/// sits at offset 28 in a well-formed stream). Ogg-Vorbis/FLAC deliberately
/// do *not* match, so they keep flowing through rodio's native decoder.
pub fn is_ogg_opus(header: &[u8]) -> bool {
header.starts_with(OGG_MAGIC) && contains(header, OPUS_HEAD_MAGIC)
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
/// A rodio [`Source`] that decodes an Ogg-Opus stream to interleaved `f32`
/// samples at libopus's native 48 kHz.
pub struct OpusSource {
decoder: Box<dyn SymphoniaDecoderTrait>,
format: Box<dyn FormatReader>,
track_id: u32,
/// Interleaved samples of the most recently decoded packet.
buffer: SampleBuffer<Sample>,
/// Read cursor into `buffer`, in samples (not frames).
span_offset: usize,
spec: SignalSpec,
total_duration: Option<Duration>,
}
impl OpusSource {
/// Builds an Opus source from a seekable byte stream. `byte_len` (the
/// content length) and `is_seekable` mirror the values the player already
/// computes for rodio; supplying them lets symphonia's Ogg reader learn
/// the final granule position (hence duration) and seek by time.
pub fn new<R>(reader: R, byte_len: Option<u64>, is_seekable: bool) -> Result<Self>
where
R: Read + Seek + Send + Sync + 'static,
{
let source = Box::new(ReadSeekSource {
inner: reader,
byte_len,
is_seekable,
});
let mss = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
let format = OggReader::try_new(mss, &FormatOptions::default())
.context("failed to open Ogg-Opus container")?;
let format: Box<dyn FormatReader> = Box::new(format);
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec == CODEC_TYPE_OPUS)
.context("Ogg stream has no Opus track")?;
let track_id = track.id;
// A registry holding only the libopus adapter — the container is
// Opus-only, so no other codec can appear.
let mut registry = CodecRegistry::new();
registry.register_all::<OpusDecoder>();
let decoder = registry
.make(&track.codec_params, &DecoderOptions::default())
.context("failed to create libopus decoder")?;
let total_duration = track
.codec_params
.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| Duration::from(base.calc_time(frames)))
.filter(|d| !d.is_zero());
// Decode the first packet up front so the struct always holds a real
// buffer and spec. symphonia's `SampleBuffer::new` divides by the
// channel count, so a zero-channel placeholder would panic; this also
// makes channel/rate queries valid before the first `next()` (rodio
// inspects the source right after construction).
let mut format = format;
let mut decoder = decoder;
let (spec, buffer) = decode_next_buffer(&mut format, &mut decoder, track_id)?
.context("Opus stream produced no audio")?;
Ok(Self {
decoder,
format,
track_id,
buffer,
span_offset: 0,
spec,
total_duration,
})
}
}
/// Decodes packets — skipping ones from other logical streams and benign
/// decode errors — until one yields audio frames, returning that frame's
/// interleaved samples and signal spec. `Ok(None)` is a clean end of stream.
fn decode_next_buffer(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn SymphoniaDecoderTrait>,
track_id: u32,
) -> Result<Option<(SignalSpec, SampleBuffer<Sample>)>> {
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
// An IO error at a page boundary is how symphonia's Ogg reader
// signals end-of-stream for a bounded file.
Err(SymphoniaError::IoError(_)) => return Ok(None),
Err(e) => return Err(anyhow!(e)).context("reading Opus packet"),
};
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) if decoded.frames() > 0 => {
let spec = *decoded.spec();
return Ok(Some((spec, copy_to_buffer(decoded, &spec))));
}
// A metadata-only packet (0 frames) or a recoverable decode error:
// skip it and keep going, matching rodio's behaviour.
Ok(_) => continue,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => return Err(anyhow!(e)).context("decoding Opus packet"),
}
}
}
/// Copies a decoded symphonia buffer into an interleaved `f32` sample buffer.
fn copy_to_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<Sample> {
let capacity = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<Sample>::new(capacity, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
impl Iterator for OpusSource {
type Item = Sample;
fn next(&mut self) -> Option<Sample> {
if self.span_offset >= self.buffer.len() {
match decode_next_buffer(&mut self.format, &mut self.decoder, self.track_id) {
Ok(Some((spec, buffer))) => {
self.spec = spec;
self.buffer = buffer;
self.span_offset = 0;
}
// Clean EOS or a fatal error both end the source.
_ => return None,
}
}
let sample = *self.buffer.samples().get(self.span_offset)?;
self.span_offset += 1;
Some(sample)
}
}
impl Source for OpusSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.len())
}
fn channels(&self) -> ChannelCount {
let count = u16::try_from(self.spec.channels.count().max(1)).unwrap_or(2);
ChannelCount::new(count).unwrap_or(ChannelCount::new(2).expect("2 is nonzero"))
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(self.spec.rate)
.unwrap_or(SampleRate::new(48_000).expect("48000 is nonzero"))
}
fn total_duration(&self) -> Option<Duration> {
self.total_duration
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
// Clamp beyond-end seeks to the end (saturating), like rodio.
let mut target = pos;
if let Some(total) = self.total_duration {
if target > total {
target = total;
}
}
// Preserve the active channel so we resume on a frame boundary.
let channels = self.channels().get() as usize;
let active_channel = self.span_offset % channels;
let seeked = self
.format
.seek(
SeekMode::Accurate,
SeekTo::Time {
time: target.into(),
track_id: Some(self.track_id),
},
)
.map_err(|e| SeekError::Other(Arc::new(e)))?;
// The demuxer moved without telling the decoder; reset it and force
// the next `next()` to refill from the new position (`span_offset`
// past the current buffer's end triggers a decode in `next()`).
self.decoder.reset();
self.span_offset = usize::MAX;
// Ogg seeks land on a page boundary before the target; fast-forward
// the residual so playback resumes at the requested instant.
self.refine_position(seeked);
// Re-align to the channel we were on before seeking.
for _ in 0..active_channel {
self.next();
}
Ok(())
}
}
impl OpusSource {
/// Skips the samples between the keyframe symphonia seeked to and the
/// exact requested timestamp. No-op when the demuxer hit the target.
fn refine_position(&mut self, seeked: SeekedTo) {
let Some(base) = self.decoder.codec_params().time_base else {
return;
};
let residual = seeked.required_ts.saturating_sub(seeked.actual_ts);
if residual == 0 {
return;
}
let seconds = Duration::from(base.calc_time(residual)).as_secs_f64();
let channels = self.channels().get() as f64;
let mut samples = (seconds * self.sample_rate().get() as f64 * channels).ceil() as usize;
samples -= samples % self.channels().get() as usize;
for _ in 0..samples {
if self.next().is_none() {
break;
}
}
}
}
/// Adapts a `Read + Seek` byte stream into symphonia's [`MediaSource`]
/// (equivalent to rodio's private `ReadSeekSource`).
struct ReadSeekSource<R> {
inner: R,
byte_len: Option<u64>,
is_seekable: bool,
}
impl<R: Read + Seek + Send + Sync> MediaSource for ReadSeekSource<R> {
fn is_seekable(&self) -> bool {
self.is_seekable
}
fn byte_len(&self) -> Option<u64> {
self.byte_len
}
}
impl<R: Read> Read for ReadSeekSource<R> {
fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
self.inner.read(buf)
}
}
impl<R: Seek> Seek for ReadSeekSource<R> {
fn seek(&mut self, pos: SeekFrom) -> IoResult<u64> {
self.inner.seek(pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_ogg_opus_header() {
// "OggS" page header with an "OpusHead" first packet.
let mut header = Vec::new();
header.extend_from_slice(b"OggS");
header.extend_from_slice(&[0u8; 24]); // rest of the 28-byte page header
header.extend_from_slice(b"OpusHead");
assert!(is_ogg_opus(&header));
}
#[test]
fn rejects_ogg_vorbis() {
let mut header = Vec::new();
header.extend_from_slice(b"OggS");
header.extend_from_slice(&[0u8; 24]);
header.extend_from_slice(b"\x01vorbis");
assert!(!is_ogg_opus(&header));
}
#[test]
fn rejects_non_ogg() {
assert!(!is_ogg_opus(b"ID3\x04OpusHead")); // mp3 with a coincidental needle
assert!(!is_ogg_opus(b"fLaC"));
assert!(!is_ogg_opus(b""));
}
}

View File

@ -22,6 +22,16 @@ pub struct Player {
impl Default for Player { impl Default for Player {
fn default() -> Self { fn default() -> Self {
Self::new(None)
}
}
impl Player {
/// Spawns the player engine on its own thread, sending audio to the
/// output device whose name contains `device` (case-insensitive), or the
/// system default when `device` is `None`. See
/// [`crate::output_device_names`] to discover names.
pub fn new(device: Option<String>) -> Self {
let (tx_engine, rx_engine) = flume::bounded(16); let (tx_engine, rx_engine) = flume::bounded(16);
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) = let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
flume::bounded(16); flume::bounded(16);
@ -36,13 +46,14 @@ impl Default for Player {
let spectrum = SpectrumTap::new(); let spectrum = SpectrumTap::new();
let engine_tap = spectrum.clone(); let engine_tap = spectrum.clone();
thread::spawn(move || { thread::spawn(move || {
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) { let engine =
Err(e) => { match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) {
error!("Could not initialize player: {}", e); Err(e) => {
return; error!("Could not initialize player: {}", e);
} return;
Ok(engine) => engine, }
}; Ok(engine) => engine,
};
engine.run(rx_engine); engine.run(rx_engine);
}); });
@ -52,9 +63,7 @@ impl Default for Player {
spectrum, spectrum,
} }
} }
}
impl Player {
/// The spectrum tap the played audio is mirrored into. /// The spectrum tap the played audio is mirrored into.
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> { pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
self.spectrum.clone() self.spectrum.clone()

View File

@ -1,5 +1,5 @@
use std::fs::File; use std::fs::File;
use std::io::BufReader; use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@ -11,6 +11,9 @@ use rodio::{Decoder, Source};
use stream_download::storage::temp::TempStorageProvider; use stream_download::storage::temp::TempStorageProvider;
use stream_download::{Settings, StreamDownload}; use stream_download::{Settings, StreamDownload};
use crate::hls::{HlsParams, HlsStream};
#[cfg(feature = "opus")]
use crate::opus_source::{is_ogg_opus, OpusSource};
use crate::spectrum_tap::{SpectrumTap, TappingSource}; use crate::spectrum_tap::{SpectrumTap, TappingSource};
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream}; use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
use std::sync::Arc; use std::sync::Arc;
@ -107,15 +110,78 @@ pub struct PlayerEngine {
pre_mute_volume: f32, pre_mute_volume: f32,
} }
/// The names of the available audio output devices, best-effort (an empty
/// list if the host cannot be queried). Shown by `crabidy-server
/// audio-devices` so a user can pick one for the `[audio] device` config.
//
// `DeviceTrait::name` is deprecated in cpal 0.17 in favor of `description()`,
// but on the ALSA backend (the Raspberry Pi target) `name()` returns the
// stable device string users already see in `aplay -l` and match against;
// `description().name()` is newer and less proven there. Keep `name()` so the
// listing and the server's device matching use the same reliable string.
#[allow(deprecated)]
pub fn output_device_names() -> Vec<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
match rodio::cpal::default_host().output_devices() {
Ok(devices) => devices.filter_map(|device| device.name().ok()).collect(),
Err(err) => {
warn!("could not enumerate audio output devices: {err}");
Vec::new()
}
}
}
/// Opens the audio output. With `preferred` set, picks the first output
/// device whose name contains that string (case-insensitive) — so a user can
/// name a memorable fragment ("Headphones", "USB") instead of the full ALSA
/// string — and falls back to the system default with a warning if none
/// matches (the Pi's default is often HDMI, which is exactly the silent-output
/// case this selection exists to fix). With `preferred` unset, uses the
/// system default.
// `name()` deprecation: see the note on `output_device_names`.
#[allow(deprecated)]
fn open_output_device(preferred: Option<&str>) -> Result<MixerDeviceSink> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
if let Some(wanted) = preferred {
let needle = wanted.to_lowercase();
let matched = rodio::cpal::default_host()
.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|device| {
device
.name()
.map(|name| name.to_lowercase().contains(&needle))
.unwrap_or(false)
})
});
match matched {
Some(device) => {
let name = device.name().unwrap_or_else(|_| "?".to_string());
info!(device = %name, "opening selected audio output device");
return DeviceSinkBuilder::from_device(device)
.with_context(|| format!("failed to open audio device {name}"))?
.open_stream()
.with_context(|| format!("failed to open a stream on audio device {name}"));
}
None => warn!(
requested = wanted,
"no audio output device name matched; using the system default"
),
}
}
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")
}
impl PlayerEngine { impl PlayerEngine {
pub fn init( pub fn init(
tx_engine: Sender<PlayerEngineCommand>, tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>, tx_player: Sender<PlayerMessage>,
runtime: Option<tokio::runtime::Handle>, runtime: Option<tokio::runtime::Handle>,
spectrum: Arc<SpectrumTap>, spectrum: Arc<SpectrumTap>,
device: Option<String>,
) -> Result<Self> { ) -> Result<Self> {
let stream = let stream = open_output_device(device.as_deref())?;
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
let sink = rodio::Player::connect_new(stream.mixer()); let sink = rodio::Player::connect_new(stream.mixer());
let (runtime, owned_runtime) = match runtime { let (runtime, owned_runtime) = match runtime {
Some(handle) => (handle, None), Some(handle) => (handle, None),
@ -206,11 +272,19 @@ impl PlayerEngine {
#[instrument(skip_all, fields(source = %display_source(source_str)))] #[instrument(skip_all, fields(source = %display_source(source_str)))]
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> { pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
// Make-before-break: open and decode the new source *before* stopping
// the current one. Opening a network stream blocks up to
// STREAM_OPEN_TIMEOUT, and the audio sink runs on its own thread, so
// the previous track stays audible throughout — the audible gap
// shrinks to the near-instant sink swap below. If the open fails, the
// current track keeps playing and the error propagates untouched. This
// covers every transition: Replace, Next, and end-of-track re-plays.
let (source, duration) = self.open_source(source_str)?;
self.reset(); self.reset();
self.append_source(source);
let duration = self.start_source(source_str)?;
let media_info = MediaInfo { duration }; let media_info = MediaInfo { duration };
self.media_info = Some(media_info.clone()); self.media_info = Some(media_info.clone());
self.current_source = Some(source_str.to_string()); self.current_source = Some(source_str.to_string());
@ -225,80 +299,164 @@ impl PlayerEngine {
Ok(media_info) Ok(media_info)
} }
/// Decodes the source and appends it (plus an end-of-stream callback) to /// Opens and decodes a source into a ready-to-play boxed rodio source,
/// the sink. Returns the total duration if known. /// touching neither the sink nor the generation counter. This is the slow,
fn start_source(&mut self, source_str: &str) -> Result<Option<Duration>> { /// network-bound step (the initial stream prefetch); keeping it off the
self.generation += 1; /// sink lets the currently-playing track continue while it runs. Returns
let duration = match Url::parse(source_str) { /// the decoded source and its total duration if known.
fn open_source(&self, source_str: &str) -> Result<(Box<dyn Source + Send>, Option<Duration>)> {
match Url::parse(source_str) {
Ok(url) if matches!(url.scheme(), "http" | "https") => { Ok(url) if matches!(url.scheme(), "http" | "https") => {
trace!( trace!(
host = url.host_str().unwrap_or("?"), host = url.host_str().unwrap_or("?"),
"opening network stream" "opening network stream"
); );
// Windowed fetching: some CDNs (googlevideo) reject plain // HLS (`.m3u8`, e.g. SoundCloud) is a playlist of mp3 segments,
// and open-ended requests with 403 and only serve bounded // streamed in order by HlsStream; every other http URL uses the
// ranges (see audio-player/src/windowed_http.rs). // windowed fetcher (some CDNs like googlevideo 403 plain and
let params = WindowedHttpParams::new(url.clone(), self.http.clone()); // open-ended requests, serving only bounded ranges — see
// audio-player/src/windowed_http.rs).
let is_hls = Path::new(url.path())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("m3u8"));
let reader = self.runtime.block_on(async { let reader = self.runtime.block_on(async {
tokio::time::timeout( // Each arm normalizes its distinct `StreamInitError<S>` to
STREAM_OPEN_TIMEOUT, // `anyhow` so the branches unify.
StreamDownload::new::<WindowedHttpStream>( let open = async {
params, if is_hls {
TempStorageProvider::new(), StreamDownload::new::<HlsStream>(
Settings::default(), HlsParams::new(url.clone(), self.http.clone()),
), TempStorageProvider::new(),
) Settings::default(),
.await )
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))? .await
.context("failed to open http stream") .map_err(|err| anyhow!("{err}"))
} else {
StreamDownload::new::<WindowedHttpStream>(
WindowedHttpParams::new(url.clone(), self.http.clone()),
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| anyhow!("{err}"))
}
};
tokio::time::timeout(STREAM_OPEN_TIMEOUT, open)
.await
.map_err(|_| {
anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}")
})?
.context("failed to open http stream")
})?; })?;
// Symphonia probes the container length during init; without a
// known byte length it seeks from the end, which rodio 0.22 turns
// into an `unreachable!` panic on a streamed source. Handing it the
// content length up front avoids that seek entirely.
let byte_len = reader.content_length(); let byte_len = reader.content_length();
let mut builder = Decoder::builder().with_data(reader).with_seekable(true); // HLS carries mp3 segments and no total length; hint mp3 and
if let Some(len) = byte_len { // mark non-seekable so symphonia never end-seeks (which panics
builder = builder.with_byte_len(len); // rodio 0.22 on a length-less stream).
} let hint = if is_hls {
if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str()) Some("mp3")
{ } else {
builder = builder.with_hint(extension); Path::new(url.path()).extension().and_then(|e| e.to_str())
} };
let decoder = builder.build().context("failed to decode http stream")?; self.build_source(
let duration = decoder.total_duration(); reader,
// Mirror the played audio into the spectrum tap (it only byte_len,
// observes; playback is unaffected). hint,
self.sink !is_hls,
.append(TappingSource::new(decoder, self.spectrum.clone())); "failed to decode http stream",
duration )
} }
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())), Ok(url) => Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
Err(_) => { Err(_) => {
trace!(path = source_str, "opening local file"); trace!(path = source_str, "opening local file");
let file = File::open(source_str) let file = File::open(source_str)
.with_context(|| format!("failed to open file {source_str}"))?; .with_context(|| format!("failed to open file {source_str}"))?;
let byte_len = file.metadata().ok().map(|m| m.len()); let byte_len = file.metadata().ok().map(|m| m.len());
let mut builder = Decoder::builder() let hint = Path::new(source_str)
.with_data(BufReader::new(file)) .extension()
.with_seekable(true); .and_then(|e| e.to_str())
if let Some(len) = byte_len { .map(str::to_owned);
builder = builder.with_byte_len(len); self.build_source(
} BufReader::new(file),
if let Some(extension) = Path::new(source_str).extension().and_then(|e| e.to_str()) byte_len,
{ hint.as_deref(),
builder = builder.with_hint(extension); true,
} "failed to decode file",
let decoder = builder.build().context("failed to decode file")?; )
let duration = decoder.total_duration();
self.sink
.append(TappingSource::new(decoder, self.spectrum.clone()));
duration
} }
}; }
}
// Fires only when the decoder ahead of it finished naturally; a /// Sniffs the stream's header and decodes it. Ogg-Opus — which symphonia
// stop/replace clears the queue before this source is ever played. /// (hence rodio) cannot decode — is routed to [`OpusSource`]; every other
/// format goes through rodio's decoder. The reader is rewound after
/// sniffing so the chosen decoder sees the whole stream, and both paths
/// are wrapped in the spectrum tap (it only observes; playback is
/// unaffected).
fn build_source<R>(
&self,
mut reader: R,
byte_len: Option<u64>,
hint: Option<&str>,
seekable: bool,
decode_err: &'static str,
) -> Result<(Box<dyn Source + Send>, Option<Duration>)>
where
R: Read + Seek + Send + Sync + 'static,
{
let mut header = [0u8; 64];
let n = read_header(&mut reader, &mut header)?;
reader
.seek(SeekFrom::Start(0))
.context("failed to rewind after sniffing the header")?;
#[cfg(feature = "opus")]
if is_ogg_opus(&header[..n]) {
debug!("decoding Ogg-Opus via libopus");
let source = OpusSource::new(reader, byte_len, seekable)?;
let duration = source.total_duration();
let tapped: Box<dyn Source + Send> =
Box::new(TappingSource::new(source, self.spectrum.clone()));
return Ok((tapped, duration));
}
// Built without the `opus` feature: rodio's decoder rejects Ogg-Opus
// (symphonia has no Opus decoder), so say why rather than let a
// "malformed stream" error stand in. Playback skips the track exactly
// as it does for any undecodable file — never a panic
// (architecture/build-features.md D6).
#[cfg(not(feature = "opus"))]
if header[..n].starts_with(b"OggS") {
return Err(anyhow!(
"cannot decode Ogg-Opus: this build has no Opus decoder (rebuild with the \
`opus` feature)"
));
}
// Symphonia probes the container length during init; without a known
// byte length it seeks from the end, which rodio 0.22 turns into an
// `unreachable!` panic on a streamed source. Handing it the content
// length up front avoids that seek entirely; for length-less streams
// (HLS) `seekable` is false so symphonia never attempts that seek.
let mut builder = Decoder::builder().with_data(reader).with_seekable(seekable);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
if let Some(extension) = hint {
builder = builder.with_hint(extension);
}
let decoder = builder.build().context(decode_err)?;
let duration = decoder.total_duration();
let tapped: Box<dyn Source + Send> =
Box::new(TappingSource::new(decoder, self.spectrum.clone()));
Ok((tapped, duration))
}
/// Appends an already-decoded source to the freshly-reset sink, followed
/// by an end-of-stream callback tagged with the current generation. The
/// callback fires only when this source finishes naturally; a later
/// stop/replace bumps the generation via `reset`, so a stale source that
/// was swapped out can never signal `Next`.
fn append_source(&mut self, source: Box<dyn Source + Send>) {
self.sink.append(source);
let tx_engine = self.tx_engine.clone(); let tx_engine = self.tx_engine.clone();
let generation = self.generation; let generation = self.generation;
self.sink.append(EmptyCallback::new(Box::new(move || { self.sink.append(EmptyCallback::new(Box::new(move || {
@ -306,8 +464,6 @@ impl PlayerEngine {
warn!("failed to send end-of-stream signal: {err}"); warn!("failed to send end-of-stream signal: {err}");
} }
}))); })));
Ok(duration)
} }
pub fn restart(&mut self) -> Result<MediaInfo> { pub fn restart(&mut self) -> Result<MediaInfo> {
@ -461,6 +617,22 @@ fn send_reply<T>(tx: Sender<T>, value: T) {
} }
} }
/// Reads up to `buf.len()` bytes for format sniffing, tolerating short reads
/// and `Interrupted`. Returns how many bytes were read (fewer than the buffer
/// only at end-of-stream), so a stream shorter than the buffer is not an error.
fn read_header<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e).context("failed to read stream header"),
}
}
Ok(filled)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

28
cbd-cli/Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "cbd-cli"
version.workspace = true
edition.workspace = true
[dependencies]
clap.workspace = true
clap_complete.workspace = true
clap_mangen.workspace = true
# The `client` feature adds the gRPC executor for the remote
# library/queue/global commands. Kept optional so binaries' build.rs can
# depend on this crate (default features = clap only) without dragging in
# tonic on every build (architecture/cli.md D1/D7).
crabidy-core = { workspace = true, optional = true }
tonic = { workspace = true, optional = true, features = [
"transport",
"codegen",
] }
tokio = { workspace = true, optional = true, features = [
"rt-multi-thread",
"macros",
] }
base64 = { workspace = true, optional = true }
[features]
default = []
client = ["dep:crabidy-core", "dep:tonic", "dep:tokio", "dep:base64"]

373
cbd-cli/src/client.rs Normal file
View File

@ -0,0 +1,373 @@
//! The gRPC executor for the remote `library`/`queue`/`global` commands
//! (`client` feature). Connects a generated `crabidy-core` client with a
//! basic-auth interceptor and runs one command against a server, printing a
//! human-readable result.
//!
//! Transport and gRPC `Status` errors are mapped to a short one-line message
//! (no color-eyre chain dump) — an ordinary "server unreachable" reads as a
//! single line (architecture/cli.md D3, quality gate "remote errors").
use std::time::Duration;
use base64::Engine;
use tonic::metadata::MetadataValue;
use tonic::service::{interceptor::InterceptedService, Interceptor};
use tonic::transport::{Channel, Endpoint};
use tonic::{Request, Status};
use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest,
SaveQueueRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest,
ToggleRepeatRequest, ToggleShuffleRequest, Track,
};
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd};
/// The reserved library path the live queue mirrors into; `queue capture`
/// captures it (architecture/crabidy-store.md).
const CURRENT_NODE: &str = "/crabidy/current";
/// How long to wait for the TCP connect before giving up — a CLI must not
/// hang forever against an unreachable server.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Per-request deadline. Generous: `CaptureLibraryNode` returns once the
/// capture is *accepted*; the walk runs server-side.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Attaches `authorization: Basic …` when a user is configured; an empty user
/// means an open server. The header value is a secret and never logged.
#[derive(Clone)]
pub struct AuthInterceptor {
header: Option<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
fn new(user: &str, password: &str) -> Result<Self, Box<dyn std::error::Error>> {
if user.is_empty() {
return Ok(Self { header: None });
}
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
let header = format!("Basic {encoded}")
.parse()
.map_err(|_| "cannot encode credentials header")?;
Ok(Self {
header: Some(header),
})
}
}
impl Interceptor for AuthInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
/// Connects (lazily) to the server described by `conn`, with a bounded
/// connect timeout and per-request deadline.
async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>> {
let endpoint = Endpoint::from_shared(conn.address.clone())?
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.connect_lazy();
let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?;
Ok(CrabidyServiceClient::with_interceptor(
endpoint,
interceptor,
))
}
/// Maps a gRPC [`Status`] to a short, single-line error — the code plus its
/// message, never an internal report chain. A transport failure surfaces as
/// `Unavailable` with tonic's one-line reason.
fn rpc_error(status: Status) -> Box<dyn std::error::Error> {
let code = status.code();
let message = status.message();
if message.is_empty() {
format!("server error: {code}").into()
} else {
format!("{message} ({code})").into()
}
}
/// Runs one remote command against the server and prints the result.
pub async fn run_remote(
conn: &Connection,
cmd: RemoteCmd,
) -> Result<(), Box<dyn std::error::Error>> {
let mut client = connect(conn).await?;
match cmd {
RemoteCmd::Library(cmd) => run_library(&mut client, cmd).await,
RemoteCmd::Queue(cmd) => run_queue(&mut client, cmd).await,
RemoteCmd::Global(cmd) => run_global(&mut client, cmd).await,
}
}
async fn run_library(
client: &mut Client,
cmd: LibraryCmd,
) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
LibraryCmd::List { path } => {
let response = client
.get_library_node(GetLibraryNodeRequest { path: path.clone() })
.await
.map_err(rpc_error)?;
match response.into_inner().node {
Some(node) => print_node(&node),
None => return Err(format!("no such library node: {path}").into()),
}
}
LibraryCmd::Create { parent, title } => {
client
.create_library_node(CreateLibraryNodeRequest {
parent_path: parent.clone(),
title: title.clone(),
})
.await
.map_err(rpc_error)?;
println!("created \"{title}\" under {parent}");
}
LibraryCmd::Rename { path, title } => {
client
.rename_library_node(RenameLibraryNodeRequest {
path: path.clone(),
new_title: title.clone(),
})
.await
.map_err(rpc_error)?;
println!("renamed {path} to \"{title}\"");
}
LibraryCmd::Delete { path } => {
client
.delete_library_node(DeleteLibraryNodeRequest { path: path.clone() })
.await
.map_err(rpc_error)?;
println!("deleted {path}");
}
LibraryCmd::Save { path, name } => {
capture(client, &path, &name, false).await?;
println!("saving {path} as /crabidy/{name} (link)");
}
LibraryCmd::Capture { path, name } => {
capture(client, &path, &name, true).await?;
println!("capturing {path} into /crabidy/{name} (download)");
}
}
Ok(())
}
async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
QueueCmd::Show => {
let response = client.init(InitRequest {}).await.map_err(rpc_error)?;
match response.into_inner().queue {
Some(queue) => print_queue(&queue),
None => println!("the queue is empty"),
}
}
QueueCmd::Append { paths } => {
let n = paths.len();
client
.append(AppendRequest { paths })
.await
.map_err(rpc_error)?;
println!("appended {n} path(s)");
}
QueueCmd::Insert { position, paths } => {
let n = paths.len();
client
.insert(InsertRequest { position, paths })
.await
.map_err(rpc_error)?;
println!("inserted {n} path(s) at {position}");
}
QueueCmd::Replace { paths } => {
let n = paths.len();
client
.replace(ReplaceRequest { paths })
.await
.map_err(rpc_error)?;
println!("replaced the queue with {n} path(s)");
}
QueueCmd::Remove { positions } => {
let n = positions.len();
client
.remove(RemoveRequest { positions })
.await
.map_err(rpc_error)?;
println!("removed {n} entry(ies)");
}
QueueCmd::Clear { keep_current } => {
client
.clear_queue(ClearQueueRequest {
exclude_current: keep_current,
})
.await
.map_err(rpc_error)?;
println!("cleared the queue");
}
QueueCmd::SetCurrent { position } => {
client
.set_current(SetCurrentRequest { position })
.await
.map_err(rpc_error)?;
println!("jumped to position {position}");
}
QueueCmd::Save { name } => {
client
.save_queue(SaveQueueRequest { name: name.clone() })
.await
.map_err(rpc_error)?;
println!("saving the queue as /crabidy/{name} (link)");
}
QueueCmd::Capture { name } => {
capture(client, CURRENT_NODE, &name, true).await?;
println!("capturing the queue into /crabidy/{name} (download)");
}
QueueCmd::Shuffle => {
client
.toggle_shuffle(ToggleShuffleRequest {})
.await
.map_err(rpc_error)?;
println!("toggled shuffle");
}
QueueCmd::Repeat => {
client
.toggle_repeat(ToggleRepeatRequest {})
.await
.map_err(rpc_error)?;
println!("toggled repeat");
}
}
Ok(())
}
async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
GlobalCmd::Play => {
client
.toggle_play(TogglePlayRequest {})
.await
.map_err(rpc_error)?;
println!("toggled play/pause");
}
GlobalCmd::Stop => {
client.stop(StopRequest {}).await.map_err(rpc_error)?;
println!("stopped");
}
GlobalCmd::Next => {
client.next(NextRequest {}).await.map_err(rpc_error)?;
println!("next track");
}
GlobalCmd::Prev => {
client.prev(PrevRequest {}).await.map_err(rpc_error)?;
println!("previous track");
}
GlobalCmd::Restart => {
client
.restart_track(RestartTrackRequest {})
.await
.map_err(rpc_error)?;
println!("restarted the current track");
}
GlobalCmd::Mute => {
client
.toggle_mute(ToggleMuteRequest {})
.await
.map_err(rpc_error)?;
println!("toggled mute");
}
GlobalCmd::Volume { delta } => {
client
.change_volume(ChangeVolumeRequest { delta })
.await
.map_err(rpc_error)?;
println!("changed volume by {delta:+}");
}
}
Ok(())
}
/// Issues a `CaptureLibraryNode` (link save or download capture).
async fn capture(
client: &mut Client,
path: &str,
name: &str,
download: bool,
) -> Result<(), Box<dyn std::error::Error>> {
client
.capture_library_node(CaptureLibraryNodeRequest {
path: path.to_string(),
name: name.to_string(),
download,
})
.await
.map_err(rpc_error)?;
Ok(())
}
/// Prints a library node: its path/title, child nodes, then tracks. Captured
/// rows are marked `*` (architecture/crabidy-store.md).
fn print_node(node: &LibraryNode) {
let marker = if node.is_captured { " *" } else { "" };
println!("{} \"{}\"{marker}", node.path, node.title);
if !node.children.is_empty() {
println!("nodes:");
for child in &node.children {
let marker = if child.is_captured { " *" } else { "" };
println!(" {} \"{}\"{marker}", child.path, child.title);
}
}
if !node.tracks.is_empty() {
println!("tracks:");
for track in &node.tracks {
println!(" {} {}", track.path, track_label(track));
}
}
if node.children.is_empty() && node.tracks.is_empty() {
println!("(empty)");
}
}
/// Prints the current queue with a marker on the current track.
fn print_queue(queue: &Queue) {
if queue.tracks.is_empty() {
println!("the queue is empty");
return;
}
for (index, track) in queue.tracks.iter().enumerate() {
let here = if index as u32 == queue.current_position {
">"
} else {
" "
};
println!("{here} {index:>3} {}", track_label(track));
}
}
/// A one-line `artist - title` label, marking captured/skipped tracks.
fn track_label(track: &Track) -> String {
let mut label = if track.artist.is_empty() {
track.title.clone()
} else {
format!("{} - {}", track.artist, track.title)
};
if track.is_captured {
label.push_str(" *");
}
if track.is_skipped {
label.push_str(" (skipped)");
}
label
}

472
cbd-cli/src/lib.rs Normal file
View File

@ -0,0 +1,472 @@
//! Shared command-line definitions for the crabidy binaries
//! (`crabidy-server`, `cbd-tui`, `cbd`), plus a feature-gated gRPC executor
//! for the remote `library`/`queue`/`global` commands.
//!
//! See `architecture/cli.md`. The clap types live here so every binary shares
//! one command surface and each binary's `build.rs` can generate shell
//! completions and man pages from its own top-level [`clap::Command`] with the
//! default (clap-only) feature set. The `client` feature adds [`run_remote`],
//! which the binaries call to execute a remote command against a running
//! server.
use clap::{Args, Parser, Subcommand, ValueEnum};
/// A server auth role. The `ValueEnum` names double as the basic-auth user
/// names the server expects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Role {
/// Full control (the normal user).
Owner,
/// Anything on the queue and playback, no library writes.
QueueOwner,
/// May browse and append to the queue; nothing else.
Appender,
}
impl Role {
/// The basic-auth user name for this role.
pub fn user_name(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::QueueOwner => "queue-owner",
Role::Appender => "queue-appender",
}
}
}
/// Connection flags shared by every remote command. Defaults match the client
/// config; a flag overrides the config-file value.
#[derive(Debug, Clone, Args)]
pub struct RemoteArgs {
/// Server address (default: the client config's, else localhost).
#[arg(short, long)]
pub address: Option<String>,
/// Basic-auth user / role name (empty against an open server).
#[arg(short, long)]
pub user: Option<String>,
/// Basic-auth password.
#[arg(short, long)]
pub password: Option<String>,
}
/// Library operations against a running server.
#[derive(Debug, Subcommand)]
pub enum LibraryCmd {
/// List a node's child nodes and tracks (default path `/`).
List {
#[arg(default_value = "/")]
path: String,
},
/// Create a child node under a creatable parent (e.g. a search term).
Create { parent: String, title: String },
/// Rename an editable node.
Rename { path: String, title: String },
/// Delete a deletable node or track (never touches the content store).
Delete { path: String },
/// Save a subtree as a link bookmark under `/crabidy/<name>`.
Save { path: String, name: String },
/// Capture a subtree under `/crabidy/<name>` (downloads audio).
Capture { path: String, name: String },
}
/// Queue operations against a running server.
#[derive(Debug, Subcommand)]
pub enum QueueCmd {
/// Print the current queue.
Show,
/// Append tracks/subtrees (by library path) to the end of the queue.
Append { paths: Vec<String> },
/// Insert tracks/subtrees at a position.
Insert { position: u32, paths: Vec<String> },
/// Replace the whole queue with the given tracks/subtrees.
Replace { paths: Vec<String> },
/// Remove queue entries by position.
Remove { positions: Vec<u32> },
/// Clear the queue (optionally keeping the current track).
Clear {
#[arg(long)]
keep_current: bool,
},
/// Jump to a queue position.
SetCurrent { position: u32 },
/// Link-save the current queue as `/crabidy/<name>`.
Save { name: String },
/// Capture the current queue into `/crabidy/<name>` (downloads audio).
Capture { name: String },
/// Toggle shuffle.
Shuffle,
/// Toggle repeat.
Repeat,
}
/// Playback and global operations against a running server.
#[derive(Debug, Subcommand)]
pub enum GlobalCmd {
/// Toggle play/pause.
Play,
/// Stop playback.
Stop,
/// Skip to the next track.
Next,
/// Go to the previous track.
Prev,
/// Restart the current track.
Restart,
/// Toggle mute.
Mute,
/// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`).
Volume { delta: f32 },
}
/// The remote command groups an executor can run (`client` feature).
#[derive(Debug, Subcommand)]
pub enum RemoteCmd {
/// Library operations.
#[command(subcommand)]
Library(LibraryCmd),
/// Queue operations.
#[command(subcommand)]
Queue(QueueCmd),
/// Playback / global operations.
#[command(subcommand)]
Global(GlobalCmd),
}
/// `guard <role> [password]`: hash a role password and (unless `--no-config`)
/// write it into `crabidy-server.toml`'s `[auth]`.
#[derive(Debug, Args)]
pub struct GuardArgs {
/// Which role's password to set.
pub role: Role,
/// The password. If omitted, it is read from stdin (pipe-friendly, and
/// keeps it out of the process list).
pub password: Option<String>,
/// Only print the PHC hash; do not modify `crabidy-server.toml`.
#[arg(long)]
pub no_config: bool,
}
/// `scan <path> [--capture|--move]`: index a music folder.
#[derive(Debug, Args)]
pub struct ScanArgs {
/// Directory to walk for playable files.
pub path: std::path::PathBuf,
/// Copy each file into the content store; the toml points there.
#[arg(long)]
pub capture: bool,
/// Move each file into the content store instead of copying.
#[arg(long, name = "move")]
pub move_: bool,
}
/// `auth <role> [password]`: write a role + cleartext password into the client
/// config.
#[derive(Debug, Args)]
pub struct AuthArgs {
/// Which role to authenticate as.
pub role: Role,
/// The cleartext password (read from stdin if omitted).
pub password: Option<String>,
/// Also set the server address in the client config.
#[arg(short, long)]
pub address: Option<String>,
}
/// `completions <shell>`: print a completion script to stdout.
#[derive(Debug, Args)]
pub struct CompletionsArgs {
/// Target shell.
pub shell: clap_complete::Shell,
}
/// `audio-devices [device]`: list the output devices, or — with `device` — set
/// `[audio] device` in `crabidy-server.toml`.
#[derive(Debug, Args)]
pub struct AudioDevicesArgs {
/// A name (or case-insensitive fragment) of an output device to write into
/// `[audio] device`. Omit to just list the available devices.
pub device: Option<String>,
}
/// Subcommands of `crabidy-server`.
#[derive(Debug, Subcommand)]
pub enum ServerCommand {
/// Set a role password (hash + write config).
Guard(GuardArgs),
/// Index a music folder with `.cbd-track.toml` files.
Scan(ScanArgs),
/// Library operations against a running server.
#[command(subcommand)]
Library(LibraryCmd),
/// Queue operations against a running server.
#[command(subcommand)]
Queue(QueueCmd),
/// Playback / global operations against a running server.
#[command(subcommand)]
Global(GlobalCmd),
/// List audio output devices, or set `[audio] device` if one is given.
AudioDevices(AudioDevicesArgs),
/// Print the build features this binary was compiled with.
Features,
/// Print a shell completion script.
Completions(CompletionsArgs),
}
/// `crabidy-server`: no subcommand runs the server.
#[derive(Debug, Parser)]
#[command(name = "crabidy-server", author, version, about)]
pub struct ServerCli {
#[command(flatten)]
pub remote: RemoteArgs,
#[command(subcommand)]
pub command: Option<ServerCommand>,
}
/// Subcommands of `cbd-tui`.
#[derive(Debug, Subcommand)]
pub enum TuiCommand {
/// Write a role + password into the client config.
Auth(AuthArgs),
#[command(subcommand)]
Library(LibraryCmd),
#[command(subcommand)]
Queue(QueueCmd),
#[command(subcommand)]
Global(GlobalCmd),
/// Print a shell completion script.
Completions(CompletionsArgs),
}
/// `cbd-tui`: no subcommand runs the TUI.
#[derive(Debug, Parser)]
#[command(name = "cbd-tui", author, version, about)]
pub struct TuiCli {
#[command(flatten)]
pub remote: RemoteArgs,
/// Show the frequency-spectrum bars under the track progress.
#[arg(long)]
pub spectrum: Option<bool>,
#[command(subcommand)]
pub command: Option<TuiCommand>,
}
/// Subcommands of `cbd` — the union of the server and client commands.
#[derive(Debug, Subcommand)]
pub enum CbdCommand {
Guard(GuardArgs),
Scan(ScanArgs),
Auth(AuthArgs),
#[command(subcommand)]
Library(LibraryCmd),
#[command(subcommand)]
Queue(QueueCmd),
#[command(subcommand)]
Global(GlobalCmd),
/// List audio output devices, or set `[audio] device` if one is given.
AudioDevices(AudioDevicesArgs),
/// Print the build features this binary was compiled with.
Features,
/// Print a shell completion script.
Completions(CompletionsArgs),
}
/// `cbd`: no subcommand runs the in-process server + TUI.
#[derive(Debug, Parser)]
#[command(name = "cbd", author, version, about)]
pub struct CbdCli {
#[command(flatten)]
pub remote: RemoteArgs,
#[arg(long)]
pub spectrum: Option<bool>,
#[command(subcommand)]
pub command: Option<CbdCommand>,
}
/// Resolved connection settings for the executor (owned, unlike the TUI's
/// `&'static ServerConfig`).
#[derive(Debug, Clone)]
pub struct Connection {
pub address: String,
pub user: String,
pub password: String,
}
/// Writes bash/zsh/fish completions and a man page for `cmd` into `dir`.
/// Used by each binary's `build.rs` (architecture/cli.md D7).
pub fn generate_assets(
mut cmd: clap::Command,
bin_name: &str,
dir: &std::path::Path,
) -> std::io::Result<()> {
use clap_complete::Shell;
std::fs::create_dir_all(dir.join("completions"))?;
std::fs::create_dir_all(dir.join("man"))?;
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
clap_complete::generate_to(shell, &mut cmd, bin_name, dir.join("completions"))?;
}
let man = clap_mangen::Man::new(cmd);
let mut out = Vec::new();
man.render(&mut out)?;
std::fs::write(dir.join("man").join(format!("{bin_name}.1")), out)?;
Ok(())
}
/// Prints a completion script for `shell` to stdout (the `completions`
/// subcommand).
pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, bin_name: &str) {
clap_complete::generate(shell, cmd, bin_name, &mut std::io::stdout());
}
#[cfg(feature = "client")]
mod client;
#[cfg(feature = "client")]
pub use client::run_remote;
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn no_subcommand_parses_on_every_binary() {
assert!(ServerCli::try_parse_from(["crabidy-server"])
.expect("server")
.command
.is_none());
assert!(TuiCli::try_parse_from(["cbd-tui"])
.expect("tui")
.command
.is_none());
assert!(CbdCli::try_parse_from(["cbd"])
.expect("cbd")
.command
.is_none());
}
#[test]
fn remote_flags_override_when_present_and_are_none_otherwise() {
let cli = TuiCli::try_parse_from(["cbd-tui", "--address", "http://x:1", "-u", "owner"])
.expect("parse");
assert_eq!(cli.remote.address.as_deref(), Some("http://x:1"));
assert_eq!(cli.remote.user.as_deref(), Some("owner"));
assert!(cli.remote.password.is_none());
let bare = TuiCli::try_parse_from(["cbd-tui"]).expect("bare");
assert!(bare.remote.address.is_none());
assert!(bare.remote.user.is_none());
}
#[test]
fn library_list_defaults_to_root() {
let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list"]).expect("parse");
match cli.command {
Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/"),
other => panic!("unexpected: {other:?}"),
}
let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list", "/tidal"])
.expect("parse");
match cli.command {
Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/tidal"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn queue_append_collects_many_paths() {
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "append", "/a", "/b", "/c"])
.expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Append { paths })) => {
assert_eq!(paths, vec!["/a", "/b", "/c"]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn queue_clear_keep_current_is_a_flag() {
let cli =
TuiCli::try_parse_from(["cbd-tui", "queue", "clear", "--keep-current"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Clear { keep_current })) => assert!(keep_current),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn global_volume_parses_a_signed_delta() {
let cli =
TuiCli::try_parse_from(["cbd-tui", "global", "volume", "--", "-0.1"]).expect("parse");
match cli.command {
Some(TuiCommand::Global(GlobalCmd::Volume { delta })) => {
assert!((delta - -0.1).abs() < f32::EPSILON);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn guard_reads_a_role_and_optional_password() {
let cli = ServerCli::try_parse_from(["crabidy-server", "guard", "owner"]).expect("parse");
match cli.command {
Some(ServerCommand::Guard(args)) => {
assert_eq!(args.role, Role::Owner);
assert!(args.password.is_none());
assert!(!args.no_config);
}
other => panic!("unexpected: {other:?}"),
}
let cli = ServerCli::try_parse_from([
"crabidy-server",
"guard",
"queue-owner",
"secret",
"--no-config",
])
.expect("parse");
match cli.command {
Some(ServerCommand::Guard(args)) => {
assert_eq!(args.role, Role::QueueOwner);
assert_eq!(args.password.as_deref(), Some("secret"));
assert!(args.no_config);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn scan_flags_parse() {
let cli = ServerCli::try_parse_from(["crabidy-server", "scan", "/music", "--capture"])
.expect("p");
match cli.command {
Some(ServerCommand::Scan(args)) => {
assert_eq!(args.path, std::path::PathBuf::from("/music"));
assert!(args.capture);
assert!(!args.move_);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn cbd_is_the_union_of_server_and_client_commands() {
assert!(matches!(
CbdCli::try_parse_from(["cbd", "guard", "owner"])
.expect("p")
.command,
Some(CbdCommand::Guard(_))
));
assert!(matches!(
CbdCli::try_parse_from(["cbd", "auth", "owner", "pw"])
.expect("p")
.command,
Some(CbdCommand::Auth(_))
));
assert!(matches!(
CbdCli::try_parse_from(["cbd", "global", "play"])
.expect("p")
.command,
Some(CbdCommand::Global(GlobalCmd::Play))
));
}
}

View File

@ -3,13 +3,23 @@ name = "cbd-tui"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# Desktop "now playing" notifications pull notify-rust and, on Linux, a
# D-Bus stack. On by default; off for a terminal-only client
# (architecture/build-features.md D1).
[features]
default = ["notifications"]
notifications = ["dep:notify-rust"]
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
cbd-cli = { workspace = true, features = ["client"] }
crabidy-core.workspace = true crabidy-core.workspace = true
crossterm.workspace = true crossterm.workspace = true
clap.workspace = true
dirs.workspace = true dirs.workspace = true
toml.workspace = true
flume.workspace = true flume.workspace = true
notify-rust.workspace = true notify-rust = { workspace = true, optional = true }
ratatui.workspace = true ratatui.workspace = true
serde.workspace = true serde.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
@ -18,3 +28,11 @@ tonic = { workspace = true, features = ["channel", "codegen"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true
# Default features only (clap-only) so asset generation stays cheap.
[build-dependencies]
cbd-cli.workspace = true
clap.workspace = true

23
cbd-tui/build.rs Normal file
View File

@ -0,0 +1,23 @@
//! Generates shell completions and a man page for `cbd-tui` from its
//! top-level clap command (architecture/cli.md D7). Written into `OUT_DIR`
//! every build, and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli`
//! is a default-features (clap-only) build-dependency, so this never pulls
//! tonic into ordinary builds.
use std::path::Path;
fn main() {
use clap::CommandFactory;
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
let bin = "cbd-tui";
let command = cbd_cli::TuiCli::command();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
}
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
}
}
}

View File

@ -42,6 +42,9 @@ pub enum Action {
ToggleRepeat, ToggleRepeat,
NextTrack, NextTrack,
PrevTrack, PrevTrack,
/// Show or hide the frequency-spectrum visualizer in the now-playing
/// pane (client-side only; the server keeps streaming the bars).
ToggleSpectrum,
// Library pane // Library pane
LibraryFirst, LibraryFirst,
LibraryLast, LibraryLast,
@ -55,6 +58,11 @@ pub enum Action {
LibraryQueueAppend, LibraryQueueAppend,
LibraryQueueReplace, LibraryQueueReplace,
LibraryToggleMark, LibraryToggleMark,
/// Enter (or leave) visual mode: while active, movement toggles the mark
/// of every row it sweeps over, so a run of items is marked by `v` then
/// moving (architecture/visual-mode.md). Entering toggles the current row;
/// `v`/`V`/`Esc` (or any other action) leaves it, marks kept.
LibraryVisualMode,
/// Open the input overlay to create a child of the currently open /// Open the input overlay to create a child of the currently open
/// library node. No-op unless that node `is_creatable` (e.g. /// library node. No-op unless that node `is_creatable` (e.g.
/// /tidal/search). While the overlay is open, keys bypass this table /// /tidal/search). While the overlay is open, keys bypass this table
@ -65,24 +73,29 @@ pub enum Action {
/// term, whose rename re-runs the search). /// term, whose rename re-runs the search).
LibraryEditNode, LibraryEditNode,
/// Delete the selected item. No-op unless the selection `is_deletable`. /// Delete the selected item. No-op unless the selection `is_deletable`.
/// Cheap deletables (search terms, bookmarks, saved queues) delete /// Deletes go through directly (no confirmation): a `/crabidy` delete
/// unconfirmed (architecture/node-editing.md, D4); captures hold /// removes only the metadata toml, never the shared content store
/// downloaded audio and open a y/N confirmation instead /// (architecture/crabidy-store.md D7).
/// (architecture/capture-deletion.md).
LibraryDeleteNode, LibraryDeleteNode,
/// Open the input overlay (prefilled with the selection's title) to /// Open the input overlay (prefilled with the selection's title) to
/// capture the selected queueable subtree as a bookmark under /// save the selected queueable subtree as a link bookmark under
/// `/bookmarks`. No-op unless the bare selection `is_queable`. /// `/crabidy/<name>`. No-op unless the bare selection `is_queable`.
LibraryCaptureNode, LibraryCaptureNode,
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads** /// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
/// every track's audio into `/captures`. No-op unless the bare /// every track's audio into the shared content store (de-duplicated).
/// selection is queueable *and* downloadable (e.g. Tidal subtrees). /// No-op unless the bare selection is queueable *and* downloadable
/// (e.g. Tidal subtrees).
LibraryDownloadNode, LibraryDownloadNode,
/// Open the `/` search input for the focused pane. Typing filters the /// Open the `/` search input for the focused pane. Typing filters the
/// pane's items live (case-insensitive substring); `Enter` keeps the /// pane's items live (case-insensitive substring); `Enter` keeps the
/// filter, `Esc` clears it. Bound in both the library and queue /// filter and returns to normal navigation of the filtered view;
/// scopes; the dispatch targets whichever pane has focus. /// `/` again re-opens the input with the current query for editing.
/// Bound in both the library and queue scopes; the dispatch targets
/// whichever pane has focus.
OpenSearch, OpenSearch,
/// Clear the focused pane's active `/` filter (bound to `Esc` in the
/// library and queue scopes). A no-op when no filter is applied.
ClearSearch,
// Queue pane // Queue pane
QueueInsertHere, QueueInsertHere,
QueueFirst, QueueFirst,
@ -97,12 +110,12 @@ pub enum Action {
QueueClearKeepCurrent, QueueClearKeepCurrent,
QueueClearAll, QueueClearAll,
/// Open the input overlay asking for a name to save the queue under /// Open the input overlay asking for a name to save the queue under
/// (persisted as `/queues/<name>`). No-op while the queue is empty. /// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
QueueSaveAs, QueueSaveAs,
/// Open the input overlay to **download-capture** the current queue /// Open the input overlay to **download-capture** the current queue
/// straight into `/captures/<name>` (the queue equivalent of the /// into `/crabidy/<name>` (the queue equivalent of the library's `W`),
/// library's `W`), instead of having to save it and then capture the /// audio de-duplicated into the shared store. No-op while the queue is
/// saved copy. No-op while the queue is empty. /// empty.
QueueDownloadCapture, QueueDownloadCapture,
// Help modal // Help modal
CloseHelp, CloseHelp,
@ -211,6 +224,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::PrevTrack, action: Action::PrevTrack,
description: "Previous track", description: "Previous track",
}, },
Binding {
scope: Scope::Global,
mods: KeyModifiers::NONE,
code: KeyCode::Char('f'),
action: Action::ToggleSpectrum,
description: "Toggle the frequency spectrum",
},
// -- Library --------------------------------------------------------- // -- Library ---------------------------------------------------------
Binding { Binding {
scope: Scope::Library, scope: Scope::Library,
@ -275,6 +295,20 @@ pub const BINDINGS: &[Binding] = &[
action: Action::LibraryToggleMark, action: Action::LibraryToggleMark,
description: "Mark/unmark selection", description: "Mark/unmark selection",
}, },
Binding {
scope: Scope::Library,
mods: KeyModifiers::NONE,
code: KeyCode::Char('v'),
action: Action::LibraryVisualMode,
description: "Visual mode: movement toggles marks (v/V; Esc to leave)",
},
Binding {
scope: Scope::Library,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('V'),
action: Action::LibraryVisualMode,
description: "Visual mode (same as v)",
},
Binding { Binding {
scope: Scope::Library, scope: Scope::Library,
mods: KeyModifiers::NONE, mods: KeyModifiers::NONE,
@ -287,7 +321,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::SHIFT, mods: KeyModifiers::SHIFT,
code: KeyCode::Char('W'), code: KeyCode::Char('W'),
action: Action::LibraryDownloadNode, action: Action::LibraryDownloadNode,
description: "Download selection as capture (can take long; same name resumes)", description: "Capture selection into /crabidy (downloads audio; can take long)",
}, },
Binding { Binding {
scope: Scope::Library, scope: Scope::Library,
@ -308,7 +342,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::NONE, mods: KeyModifiers::NONE,
code: KeyCode::Char('d'), code: KeyCode::Char('d'),
action: Action::LibraryDeleteNode, action: Action::LibraryDeleteNode,
description: "Delete selection (captures ask y/N, and delete files)", description: "Delete selection",
}, },
Binding { Binding {
scope: Scope::Library, scope: Scope::Library,
@ -338,6 +372,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::OpenSearch, action: Action::OpenSearch,
description: "Filter this view (type to search, Enter keeps, Esc clears)", description: "Filter this view (type to search, Enter keeps, Esc clears)",
}, },
Binding {
scope: Scope::Library,
mods: KeyModifiers::NONE,
code: KeyCode::Esc,
action: Action::ClearSearch,
description: "Clear the search filter",
},
// -- Queue ----------------------------------------------------------- // -- Queue -----------------------------------------------------------
Binding { Binding {
scope: Scope::Queue, scope: Scope::Queue,
@ -435,7 +476,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::SHIFT, mods: KeyModifiers::SHIFT,
code: KeyCode::Char('W'), code: KeyCode::Char('W'),
action: Action::QueueDownloadCapture, action: Action::QueueDownloadCapture,
description: "Capture the queue (download audio; same name resumes)", description: "Capture the queue into /crabidy (downloads audio)",
}, },
Binding { Binding {
scope: Scope::Queue, scope: Scope::Queue,
@ -444,6 +485,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::OpenSearch, action: Action::OpenSearch,
description: "Filter this view (type to search, Enter keeps, Esc clears)", description: "Filter this view (type to search, Enter keeps, Esc clears)",
}, },
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
code: KeyCode::Esc,
action: Action::ClearSearch,
description: "Clear the search filter",
},
// -- Help modal ------------------------------------------------------ // -- Help modal ------------------------------------------------------
Binding { Binding {
scope: Scope::Help, scope: Scope::Help,
@ -763,6 +811,50 @@ mod tests {
); );
} }
#[test]
fn spectrum_moved_off_v_to_f() {
// The frequency-spectrum toggle now lives on Global `f`, in any focus.
for focus in [UiFocus::Library, UiFocus::Queue] {
assert_eq!(
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)),
Some(Action::ToggleSpectrum)
);
}
// `v`/`V` no longer toggle the spectrum (they are visual mode now).
assert_ne!(
lookup(
UiFocus::Library,
false,
key(KeyCode::Char('v'), KeyModifiers::NONE)
),
Some(Action::ToggleSpectrum)
);
}
#[test]
fn v_and_v_enter_visual_in_library_only() {
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual mode
// in the library; neither is bound in the queue (no marks there).
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
assert_eq!(
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
Some(Action::LibraryVisualMode)
);
assert_eq!(
lookup(UiFocus::Library, false, key(KeyCode::Char('V'), mods)),
Some(Action::LibraryVisualMode)
);
assert_eq!(
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
None
);
assert_eq!(
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
None
);
}
}
#[test] #[test]
fn open_help_swallows_everything_but_close() { fn open_help_swallows_everything_but_close() {
for focus in [UiFocus::Library, UiFocus::Queue] { for focus in [UiFocus::Library, UiFocus::Queue] {
@ -791,11 +883,12 @@ mod tests {
#[test] #[test]
fn help_scope_never_matches_while_help_is_closed() { fn help_scope_never_matches_while_help_is_closed() {
// Esc is only bound inside the modal. // With help closed, Esc drives the pane's ClearSearch, never the
// modal's CloseHelp — Help-scope bindings don't leak into navigation.
for focus in [UiFocus::Library, UiFocus::Queue] { for focus in [UiFocus::Library, UiFocus::Queue] {
assert_eq!( assert_eq!(
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)), lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
None Some(Action::ClearSearch)
); );
} }
} }

View File

@ -29,6 +29,11 @@ pub struct Library {
filter: Filter, filter: Filter,
parent: Option<String>, parent: Option<String>,
positions: HashMap<String, usize>, positions: HashMap<String, usize>,
/// Visual (paint-select) mode: `Some(anchor_view)` while active. Movement
/// marks the contiguous range between the anchor (where `v` was pressed)
/// and the cursor, toggling rows as they enter/leave it — so moving back
/// cleanly reverses (architecture/visual-mode.md).
visual: Option<usize>,
tx: Sender<MessageFromUi>, tx: Sender<MessageFromUi>,
} }
@ -43,6 +48,7 @@ impl Library {
filter: Filter::default(), filter: Filter::default(),
positions: HashMap::new(), positions: HashMap::new(),
parent: None, parent: None,
visual: None,
tx, tx,
} }
} }
@ -173,11 +179,16 @@ impl Library {
*self.positions.get(&self.path).unwrap_or(&0) *self.positions.get(&self.path).unwrap_or(&0)
} }
pub fn toggle_mark(&mut self) { pub fn toggle_mark(&mut self) {
if let Some(real) = self if let Some(view) = self.list_state.selected() {
.list_state self.toggle_mark_view(view);
.selected() }
.and_then(|view| self.filter.to_real(view)) }
{
/// Toggle the mark of the row at a **view** index (mapped through the `/`
/// filter to its real row), honoring the `is_queable` gate. Shared by
/// `toggle_mark` and visual-mode painting.
fn toggle_mark_view(&mut self, view: usize) {
if let Some(real) = self.filter.to_real(view) {
let item = &mut self.list[real]; let item = &mut self.list[real];
if !item.is_queable { if !item.is_queable {
return; return;
@ -185,6 +196,63 @@ impl Library {
item.marked = !item.marked; item.marked = !item.marked;
} }
} }
/// Whether visual (paint-select) mode is active.
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// Titles of the currently marked rows, in list order (inspection helper).
pub fn marked_titles(&self) -> Vec<String> {
self.list
.iter()
.filter(|i| i.marked)
.map(|i| i.title.clone())
.collect()
}
/// Enter or leave visual mode. Entering anchors at the current row and
/// toggles its mark (vim includes the row you start on, D2); leaving keeps
/// the marks.
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.list_state.selected().unwrap_or(0));
self.toggle_mark();
}
}
/// Leave visual mode (marks kept). Idempotent.
pub fn exit_visual(&mut self) {
self.visual = None;
}
/// The current cursor position as a view index.
pub fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Repaint after a visual-mode move from `from_view` to `to_view`. The
/// selection is the contiguous range `[anchor, cursor]`; a move that grows
/// or shrinks it toggles exactly the rows whose range membership changed
/// (relative to the anchor), so moving back reverses a move cleanly and the
/// row you turn around on is never stranded. No-op unless visual is active.
pub fn paint_between(&mut self, from_view: usize, to_view: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&view);
let in_new = (new_lo..=new_hi).contains(&view);
if in_old != in_new {
self.toggle_mark_view(view);
}
}
}
pub fn remove_marks(&mut self) { pub fn remove_marks(&mut self) {
if self.list.iter().any(|i| i.marked) { if self.list.iter().any(|i| i.marked) {
self.list self.list
@ -226,6 +294,7 @@ impl Library {
is_deletable: node.tracks_deletable, is_deletable: node.tracks_deletable,
is_downloadable: node.is_downloadable, is_downloadable: node.is_downloadable,
is_skipped: t.is_skipped, is_skipped: t.is_skipped,
is_captured: t.is_captured,
}) })
.chain(node.children.iter().map(|c| UiItem { .chain(node.children.iter().map(|c| UiItem {
path: c.path.clone(), path: c.path.clone(),
@ -238,6 +307,7 @@ impl Library {
is_deletable: c.is_deletable, is_deletable: c.is_deletable,
is_downloadable: c.is_downloadable, is_downloadable: c.is_downloadable,
is_skipped: false, is_skipped: false,
is_captured: c.is_captured,
})) }))
.collect(); .collect();
@ -269,7 +339,8 @@ impl Library {
if i.is_creatable { if i.is_creatable {
text.push_str(" [%]"); text.push_str(" [%]");
} }
// Modifiable items advertise their keys: [e], [d] or [ed]. // Modifiable items advertise their action keys: [e], [d] or
// [ed].
if i.is_editable || i.is_deletable { if i.is_editable || i.is_deletable {
text.push_str(" ["); text.push_str(" [");
if i.is_editable { if i.is_editable {
@ -280,6 +351,12 @@ impl Library {
} }
text.push(']'); text.push(']');
} }
// A trailing `↓` (a status, not an action, so kept outside
// the key brackets) marks a row whose audio is in the content
// store (downloaded).
if i.is_captured {
text.push_str("");
}
let mut style = if i.marked { let mut style = if i.marked {
Style::default() Style::default()
.fg(COLOR_GREEN) .fg(COLOR_GREEN)
@ -312,7 +389,10 @@ impl Library {
} else { } else {
COLOR_PRIMARY_DARK COLOR_PRIMARY_DARK
})) }))
.title(if let Some(query) = self.filter.query() { .title(if self.visual.is_some() {
// Visual (paint-select) mode: movement toggles marks.
format!("{} — VISUAL", self.title)
} else if let Some(query) = self.filter.query() {
// Search mode: show the live query with a cursor. // Search mode: show the live query with a cursor.
format!("{} — /{query}", self.title) format!("{} — /{query}", self.title)
} else if self.is_creatable { } else if self.is_creatable {
@ -360,3 +440,53 @@ impl StatefulList for Library {
self.list_state.selected() self.list_state.selected()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::LibraryNodeChild;
/// Renders a `Library` into an 80x24 test terminal and returns its text.
fn render_text(library: &mut Library) -> String {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| library.render(f, f.area(), true))
.expect("draw library");
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
#[test]
fn captured_rows_render_a_trailing_arrow() {
let (tx, _rx) = flume::unbounded();
let mut library = Library::new(tx);
library.update(LibraryNode {
path: "/crabidy/mix".to_string(),
title: "mix".to_string(),
children: vec![LibraryNodeChild {
is_captured: true,
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
}],
parent: Some("/crabidy".to_string()),
tracks: Vec::new(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
});
let text = render_text(&mut library);
assert!(
text.contains("album ↓"),
"captured row carries a trailing down-arrow: {text}"
);
}
}

View File

@ -55,6 +55,10 @@ struct UiItem {
/// The track has no playable audio (`Track.is_skipped`) — rendered /// The track has no playable audio (`Track.is_skipped`) — rendered
/// red; playback skips it. Always false for nodes. /// red; playback skips it. Always false for nodes.
is_skipped: bool, is_skipped: bool,
/// The item's audio is present in the capture content store
/// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered
/// with a trailing `↓` marker at the end of the row.
is_captured: bool,
} }
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
@ -68,8 +72,9 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
/// Library path of the continuously persisted live queue — what the /// Library path of the continuously persisted live queue — what the
/// queue pane's `W` download-captures (server: `queue_store`'s /// queue pane's `W` download-captures (server: `queue_store`'s
/// `CURRENT_QUEUE_NAME` under the `/queues` instance). /// `CURRENT_QUEUE_NAME`, now mirrored under the single `/crabidy`
const CURRENT_QUEUE_PATH: &str = "/queues/current"; /// provider).
const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
// FIXME: Rename this // FIXME: Rename this
pub enum MessageToUi { pub enum MessageToUi {
@ -99,11 +104,11 @@ pub enum MessageFromUi {
path: String, path: String,
}, },
/// Save the current queue under a name (server-side snapshot; appears /// Save the current queue under a name (server-side snapshot; appears
/// as `/queues/<name>` in the library on the next visit). /// as `/crabidy/<name>` in the library on the next visit).
SaveQueue(String), SaveQueue(String),
/// Capture the queueable subtree at `path` as the bookmark `name` /// Capture the queueable subtree at `path` as the bookmark `name`
/// (structure-preserving snapshot under `/bookmarks/<name>`), or — /// (structure-preserving snapshot under `/crabidy/<name>`), or —
/// with `download` — as the capture `name` under `/captures/<name>` /// with `download` — as the capture `name` under `/crabidy/<name>`
/// with every track's audio downloaded next to its toml. /// with every track's audio downloaded next to its toml.
CaptureNode { CaptureNode {
path: String, path: String,
@ -247,24 +252,6 @@ pub struct InputState {
pub buffer: String, pub buffer: String,
} }
/// A delete waiting for its `y` — opened instead of sending when the
/// selection is a capture (`d` there destroys downloaded data on disk,
/// architecture/capture-deletion.md). Modal like [`InputState`]: while
/// `Some`, keys go to [`App::handle_confirm_key`] only.
pub struct ConfirmDelete {
pub path: String,
/// Display title of the doomed item, shown in the prompt.
pub title: String,
}
/// Whether deleting `path` needs a confirmation: deletes under
/// `/captures` remove downloaded audio from disk — expensive to redo —
/// while every other deletable node (search terms, bookmarks, saved
/// queues) is cheap to recreate and stays one keypress.
fn delete_needs_confirmation(path: &str) -> bool {
path == "/captures" || path.starts_with("/captures/")
}
/// An open `/` search input, filtering one pane live as the user types. /// An open `/` search input, filtering one pane live as the user types.
/// The filter itself lives on the pane (it survives closing this input); /// The filter itself lives on the pane (it survives closing this input);
/// this only holds the editing buffer and which pane is being filtered. /// this only holds the editing buffer and which pane is being filtered.
@ -282,9 +269,6 @@ pub struct App {
/// `Some` while the input overlay is open; takes precedence over the /// `Some` while the input overlay is open; takes precedence over the
/// bindings table (checked first in the event loop). /// bindings table (checked first in the event loop).
pub input: Option<InputState>, pub input: Option<InputState>,
/// `Some` while a capture delete awaits confirmation; modal like
/// `input` and checked before it in the event loop.
pub confirm: Option<ConfirmDelete>,
/// `Some` while a `/` search input is open; modal like the others. /// `Some` while a `/` search input is open; modal like the others.
pub search: Option<SearchState>, pub search: Option<SearchState>,
/// Progress of running (and recently finished) captures, rendered as /// Progress of running (and recently finished) captures, rendered as
@ -305,7 +289,6 @@ impl App {
focus: UiFocus::Library, focus: UiFocus::Library,
show_help: false, show_help: false,
input: None, input: None,
confirm: None,
search: None, search: None,
captures: CaptureBoard::default(), captures: CaptureBoard::default(),
library, library,
@ -315,21 +298,6 @@ impl App {
} }
} }
/// Handles one key while a delete confirmation is open
/// (`confirm.is_some()`). Only `y`/`Y` confirms and sends the delete;
/// every other key cancels — the safe answer is any answer.
pub fn handle_confirm_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
let Some(confirm) = self.confirm.take() else {
return;
};
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let _ = self
.tx
.send(MessageFromUi::DeleteNode { path: confirm.path });
}
}
/// Handles one key while a `/` search input is open /// Handles one key while a `/` search input is open
/// (`search.is_some()`). Typing filters the focused pane live; /// (`search.is_some()`). Typing filters the focused pane live;
/// `Enter` keeps the filter and returns to navigation; `Esc` clears /// `Enter` keeps the filter and returns to navigation; `Esc` clears
@ -433,7 +401,41 @@ impl App {
/// modal, list selections) or send the matching [`MessageFromUi`] via /// modal, list selections) or send the matching [`MessageFromUi`] via
/// `tx`. Send failures are ignored like everywhere else in this module — /// `tx`. Send failures are ignored like everywhere else in this module —
/// the orchestrator side owns error reporting. /// the orchestrator side owns error reporting.
/// Run a library cursor move, painting the swept `(old, new]` range when
/// visual mode is active (architecture/visual-mode.md D3).
fn library_move(&mut self, mv: impl FnOnce(&mut Library)) {
if self.library.is_visual() {
let old = self.library.selected_view();
mv(&mut self.library);
let new = self.library.selected_view();
if let (Some(old), Some(new)) = (old, new) {
self.library.paint_between(old, new);
}
} else {
mv(&mut self.library);
}
}
pub fn dispatch(&mut self, action: Action) -> DispatchResult { pub fn dispatch(&mut self, action: Action) -> DispatchResult {
// Visual mode (library paint-select): the six movements paint (handled
// in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other
// action leaves visual mode before running (architecture/visual-mode.md).
let was_visual = self.library.is_visual();
if was_visual
&& !matches!(
action,
Action::LibraryFirst
| Action::LibraryLast
| Action::LibraryNext
| Action::LibraryPrev
| Action::LibraryJumpDown
| Action::LibraryJumpUp
| Action::LibraryVisualMode
| Action::ClearSearch
)
{
self.library.exit_visual();
}
match action { match action {
Action::Quit => return DispatchResult::Quit, Action::Quit => return DispatchResult::Quit,
Action::OpenHelp => self.show_help = true, Action::OpenHelp => self.show_help = true,
@ -462,18 +464,20 @@ impl App {
} }
Action::NextTrack => self.queue.play_next(), Action::NextTrack => self.queue.play_next(),
Action::PrevTrack => self.queue.play_prev(), Action::PrevTrack => self.queue.play_prev(),
Action::LibraryFirst => self.library.first(), Action::ToggleSpectrum => self.now_playing.toggle_spectrum(),
Action::LibraryLast => self.library.last(), Action::LibraryFirst => self.library_move(|l| l.first()),
Action::LibraryNext => self.library.next(), Action::LibraryLast => self.library_move(|l| l.last()),
Action::LibraryPrev => self.library.prev(), Action::LibraryNext => self.library_move(|l| l.next()),
Action::LibraryJumpDown => self.library.down(), Action::LibraryPrev => self.library_move(|l| l.prev()),
Action::LibraryJumpUp => self.library.up(), Action::LibraryJumpDown => self.library_move(|l| l.down()),
Action::LibraryJumpUp => self.library_move(|l| l.up()),
Action::LibraryAscend => self.library.ascend(), Action::LibraryAscend => self.library.ascend(),
Action::LibraryDive => self.library.dive(), Action::LibraryDive => self.library.dive(),
Action::LibraryQueueNext => self.library.queue_queue(), Action::LibraryQueueNext => self.library.queue_queue(),
Action::LibraryQueueAppend => self.library.queue_append(), Action::LibraryQueueAppend => self.library.queue_append(),
Action::LibraryQueueReplace => self.library.queue_replace(), Action::LibraryQueueReplace => self.library.queue_replace(),
Action::LibraryToggleMark => self.library.toggle_mark(), Action::LibraryToggleMark => self.library.toggle_mark(),
Action::LibraryVisualMode => self.library.toggle_visual(),
Action::LibraryCreateNode => { Action::LibraryCreateNode => {
// Opens the input overlay when the open library node is // Opens the input overlay when the open library node is
// creatable; silently ignored otherwise. // creatable; silently ignored otherwise.
@ -497,16 +501,11 @@ impl App {
} }
} }
Action::LibraryDeleteNode => { Action::LibraryDeleteNode => {
// Cheap deletables (search terms, bookmarks, saved queues) // Deletes go through directly on every provider: audio lives
// stay unconfirmed by design (architecture/node-editing.md, // in a shared content store that track-deletion never
// D4); captures hold downloaded audio and get a y/N prompt // touches, so capture deletes are no longer destructive.
// first (architecture/capture-deletion.md). if let Some((path, _title)) = self.library.selected_deletable() {
if let Some((path, title)) = self.library.selected_deletable() { let _ = self.tx.send(MessageFromUi::DeleteNode { path });
if delete_needs_confirmation(&path) {
self.confirm = Some(ConfirmDelete { path, title });
} else {
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
}
} }
} }
Action::LibraryCaptureNode => { Action::LibraryCaptureNode => {
@ -550,6 +549,21 @@ impl App {
Self::apply_search(&mut self.library, &mut self.queue, &search); Self::apply_search(&mut self.library, &mut self.queue, &search);
self.search = Some(search); self.search = Some(search);
} }
Action::ClearSearch => {
if was_visual {
// In visual mode `Esc` leaves the mode (marks kept) and
// does not also clear the search filter.
self.library.exit_visual();
} else {
// `Esc` in normal navigation clears the focused pane's
// filter, restoring the full listing. A no-op when nothing
// is filtered.
match self.focus {
UiFocus::Library => self.library.set_filter(None),
UiFocus::Queue => self.queue.set_filter(None),
}
}
}
Action::QueueInsertHere => { Action::QueueInsertHere => {
if let Some(selected) = self.queue.selected() { if let Some(selected) = self.queue.selected() {
self.library.queue_insert(selected); self.library.queue_insert(selected);
@ -582,7 +596,7 @@ impl App {
} }
Action::QueueDownloadCapture => { Action::QueueDownloadCapture => {
// Capture the live queue directly (its continuously // Capture the live queue directly (its continuously
// persisted `/queues/current` snapshot), so the user need // persisted `/crabidy/current` snapshot), so the user need
// not save-then-capture. Empty queue: nothing to capture. // not save-then-capture. Empty queue: nothing to capture.
if !self.queue.is_empty() { if !self.queue.is_empty() {
self.input = Some(InputState { self.input = Some(InputState {
@ -658,22 +672,6 @@ impl App {
} }
} }
// The capture-delete confirmation: same one-line slot as the text
// input (the two are never open together), red — it is the one
// destructive prompt in the UI.
if let Some(confirm) = &self.confirm {
let area = main[0];
if area.height >= 3 && area.width >= 4 {
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
f.render_widget(Clear, line);
f.render_widget(
Paragraph::new(format!("delete {}? [y/N]", confirm.title))
.style(Style::default().fg(COLOR_RED)),
line,
);
}
}
// Capture progress lines: stacked up from the bottom of the library // Capture progress lines: stacked up from the bottom of the library
// pane, above the input overlay when that is open. Failures render // pane, above the input overlay when that is open. Failures render
// red. At most three — more concurrent captures than that keep // red. At most three — more concurrent captures than that keep
@ -681,11 +679,7 @@ impl App {
let capture_lines = self.captures.lines(); let capture_lines = self.captures.lines();
if !capture_lines.is_empty() { if !capture_lines.is_empty() {
let area = main[0]; let area = main[0];
let bottom_offset = if self.input.is_some() || self.confirm.is_some() { let bottom_offset = if self.input.is_some() { 3 } else { 2 };
3
} else {
2
};
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() { for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
let offset = bottom_offset + i as u16; let offset = bottom_offset + i as u16;
if area.height <= offset + 1 || area.width < 4 { if area.height <= offset + 1 || area.width < 4 {
@ -793,6 +787,7 @@ mod tests {
is_creatable: true, is_creatable: true,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false, tracks_deletable: false,
is_captured: false,
} }
} }
@ -822,6 +817,7 @@ mod tests {
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false, tracks_deletable: false,
is_captured: false,
} }
} }
@ -831,6 +827,200 @@ mod tests {
} }
} }
/// A listing whose child queueability follows `queueable` per title.
fn mixed_listing(items: &[(&str, bool)]) -> LibraryNode {
use crabidy_core::proto::crabidy::LibraryNodeChild;
LibraryNode {
path: "/fs/music".to_string(),
title: "music".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
children: items
.iter()
.map(|(t, q)| LibraryNodeChild::new(format!("/fs/music/{t}"), t.to_string(), *q))
.collect(),
tracks: Vec::new(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
#[test]
fn visual_enter_toggles_current_mark() {
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode);
assert!(app.library.is_visual(), "visual mode active");
assert_eq!(app.library.marked_titles(), vec!["alpha".to_string()]);
}
#[test]
fn visual_move_paints_swept_range() {
// Step moves paint one row each.
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode); // marks alpha (anchor)
let _ = app.dispatch(Action::LibraryNext); // marks beta
let _ = app.dispatch(Action::LibraryNext); // marks gamma
assert_eq!(
app.library.marked_titles(),
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]
);
}
#[test]
fn visual_jump_paints_whole_span() {
// A jump paints every row in the span, not just the endpoint.
let (mut app, _rx) = app();
app.library
.update(children_listing(&["a", "b", "c", "d", "e"]));
let _ = app.dispatch(Action::LibraryVisualMode); // marks a
let _ = app.dispatch(Action::LibraryLast); // paints b,c,d,e
assert_eq!(app.library.marked_titles().len(), 5);
}
#[test]
fn visual_back_sweep_unpaints() {
// Retreating shrinks the [anchor, cursor] range: the row turned around
// on (gamma) is unmarked, not stranded.
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor)
let _ = app.dispatch(Action::LibraryNext); // beta
let _ = app.dispatch(Action::LibraryNext); // gamma
let _ = app.dispatch(Action::LibraryPrev); // back onto beta
assert_eq!(
app.library.marked_titles(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn visual_down_then_fully_up_leaves_only_the_anchor() {
// Regression: going down and back up to the start must not strand the
// furthest row marked (the reported bug).
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor)
let _ = app.dispatch(Action::LibraryNext); // beta
let _ = app.dispatch(Action::LibraryNext); // gamma
let _ = app.dispatch(Action::LibraryPrev); // beta
let _ = app.dispatch(Action::LibraryPrev); // alpha
assert_eq!(
app.library.marked_titles(),
vec!["alpha".to_string()],
"only the anchor stays marked after returning to it"
);
}
#[test]
fn visual_exit_keeps_marks() {
// Second v exits; marks persist.
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode);
let _ = app.dispatch(Action::LibraryNext);
let before = app.library.marked_titles();
let _ = app.dispatch(Action::LibraryVisualMode);
assert!(!app.library.is_visual());
assert_eq!(app.library.marked_titles(), before);
}
#[test]
fn visual_esc_exits_keeping_marks() {
// Esc exits visual and keeps the marks.
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode);
let _ = app.dispatch(Action::LibraryNext);
let _ = app.dispatch(Action::ClearSearch);
assert!(!app.library.is_visual());
assert_eq!(
app.library.marked_titles(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn visual_non_move_action_exits_then_runs() {
let (mut app, rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode); // alpha
let _ = app.dispatch(Action::LibraryNext); // beta
let _ = app.dispatch(Action::LibraryQueueAppend); // exits + appends marks
assert!(!app.library.is_visual(), "append leaves visual mode");
assert!(
app.library.marked_titles().is_empty(),
"append consumes the marks"
);
match rx.try_recv() {
Ok(MessageFromUi::AppendTracks(paths)) => assert_eq!(paths.len(), 2),
_ => panic!("expected AppendTracks(2)"),
}
}
#[test]
fn visual_exits_on_node_or_focus_change() {
for action in [
Action::LibraryDive,
Action::LibraryAscend,
Action::CycleFocus,
] {
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
let _ = app.dispatch(Action::LibraryVisualMode);
assert!(app.library.is_visual());
let _ = app.dispatch(action);
assert!(!app.library.is_visual(), "{action:?} leaves visual mode");
}
}
#[test]
fn visual_paint_respects_is_queable() {
// Sweeping over a non-queueable row leaves it unmarked, like `s`.
let (mut app, _rx) = app();
app.library
.update(mixed_listing(&[("a", true), ("b", false), ("c", true)]));
let _ = app.dispatch(Action::LibraryVisualMode); // marks a
let _ = app.dispatch(Action::LibraryNext); // sweeps b (not queueable)
let _ = app.dispatch(Action::LibraryNext); // marks c
assert_eq!(
app.library.marked_titles(),
vec!["a".to_string(), "c".to_string()]
);
}
#[test]
fn esc_in_visual_only_exits_visual_keeping_the_filter() {
use crossterm::event::KeyCode;
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
// Apply a filter and return to navigation.
let _ = app.dispatch(Action::OpenSearch);
search_key(&mut app, "a");
app.handle_search_key(key(KeyCode::Enter));
assert_eq!(app.library.filter_query(), Some("a"));
// Enter visual, then Esc: leaves visual but keeps the filter.
let _ = app.dispatch(Action::LibraryVisualMode);
let _ = app.dispatch(Action::ClearSearch);
assert!(!app.library.is_visual());
assert_eq!(app.library.filter_query(), Some("a"), "filter untouched");
// A second Esc (not visual) now clears the filter.
let _ = app.dispatch(Action::ClearSearch);
assert_eq!(app.library.filter_query(), None);
}
#[test] #[test]
fn slash_filters_the_focused_library_pane_enter_keeps_esc_clears() { fn slash_filters_the_focused_library_pane_enter_keeps_esc_clears() {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
@ -863,6 +1053,31 @@ mod tests {
assert_eq!(app.library.get_size(), 3, "full listing restored"); assert_eq!(app.library.get_size(), 3, "full listing restored");
} }
#[test]
fn esc_in_navigation_clears_an_active_filter() {
use crossterm::event::KeyCode;
let (mut app, _rx) = app();
app.library
.update(children_listing(&["alpha", "beta", "gamma"]));
// Filter, then Enter back to normal navigation (the filter stays).
let _ = app.dispatch(Action::OpenSearch);
search_key(&mut app, "bet");
app.handle_search_key(key(KeyCode::Enter));
assert!(app.search.is_none());
assert_eq!(app.library.filter_query(), Some("bet"));
// Esc now resolves to ClearSearch (no modal open), clearing the
// filter while staying in normal navigation.
let _ = app.dispatch(Action::ClearSearch);
assert_eq!(app.library.filter_query(), None);
assert_eq!(app.library.get_size(), 3, "full listing restored");
// With no filter, ClearSearch is a harmless no-op.
let _ = app.dispatch(Action::ClearSearch);
assert_eq!(app.library.filter_query(), None);
}
#[test] #[test]
fn search_targets_the_focused_pane() { fn search_targets_the_focused_pane() {
let (mut app, _rx) = app(); let (mut app, _rx) = app();
@ -1126,104 +1341,64 @@ mod tests {
} }
other => panic!("expected DeleteNode, got {:?}", other.is_ok()), other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
} }
assert!(app.confirm.is_none(), "cheap deletes are unconfirmed");
} }
/// A /captures/mix listing: one deletable track (tracks come first in /// A /crabidy/mix listing: one deletable track (tracks come first in
/// the pane) and one deletable album folder. /// the pane) and one deletable album folder.
fn captures_listing() -> LibraryNode { fn captures_listing() -> LibraryNode {
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track}; use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
LibraryNode { LibraryNode {
path: "/captures/mix".to_string(), path: "/crabidy/mix".to_string(),
title: "mix".to_string(), title: "mix".to_string(),
children: vec![LibraryNodeChild { children: vec![LibraryNodeChild {
is_deletable: true, is_deletable: true,
..LibraryNodeChild::new( ..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
"/captures/mix/album".to_string(),
"album".to_string(),
true,
)
}], }],
parent: Some("/captures".to_string()), parent: Some("/crabidy".to_string()),
tracks: vec![Track { tracks: vec![Track {
path: "/captures/mix/0001%20song.cbd-track.toml".to_string(), path: "/crabidy/mix/0001%20song.cbd-track.toml".to_string(),
artist: "artist".to_string(), artist: "artist".to_string(),
title: "song".to_string(), title: "song".to_string(),
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}], }],
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: true, tracks_deletable: true,
is_captured: false,
} }
} }
#[test] #[test]
fn capture_deletes_open_a_confirmation_instead_of_sending() { fn capture_deletes_send_immediately_without_confirmation() {
let (mut app, rx) = app(); let (mut app, rx) = app();
app.library.update(captures_listing()); app.library.update(captures_listing());
// The first item is the capture's track, deletable via the node's // The first item is the capture's track, deletable via the node's
// tracks_deletable flag. // tracks_deletable flag. Deletes under `/crabidy` now go through
// directly: audio lives in a shared content store that
// track-deletion never touches, so the delete is not destructive.
let _ = app.dispatch(Action::LibraryDeleteNode); let _ = app.dispatch(Action::LibraryDeleteNode);
assert!(rx.try_recv().is_err(), "nothing sent before the y");
let confirm = app.confirm.as_ref().expect("confirmation open");
assert_eq!(confirm.path, "/captures/mix/0001%20song.cbd-track.toml");
assert_eq!(confirm.title, "artist - song");
app.handle_confirm_key(key(crossterm::event::KeyCode::Char('y')));
assert!(app.confirm.is_none());
match rx.try_recv() { match rx.try_recv() {
Ok(MessageFromUi::DeleteNode { path }) => { Ok(MessageFromUi::DeleteNode { path }) => {
assert_eq!(path, "/captures/mix/0001%20song.cbd-track.toml"); assert_eq!(path, "/crabidy/mix/0001%20song.cbd-track.toml");
} }
other => panic!("expected DeleteNode, got {:?}", other.is_ok()), other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
} }
// Folders below the top level are deletable (and confirmed) too. // Folders below the top level delete immediately too.
let mut app_state = app;
app_state.library.update(captures_listing());
app_state.library.last();
let _ = app_state.dispatch(Action::LibraryDeleteNode);
let confirm = app_state.confirm.as_ref().expect("confirmation open");
assert_eq!(confirm.path, "/captures/mix/album");
}
#[test]
fn capture_delete_confirmations_cancel_on_anything_but_y() {
use crossterm::event::KeyCode;
let (mut app, rx) = app();
for cancel in [KeyCode::Esc, KeyCode::Char('n'), KeyCode::Enter] {
app.library.update(captures_listing());
let _ = app.dispatch(Action::LibraryDeleteNode);
assert!(app.confirm.is_some(), "confirmation open");
app.handle_confirm_key(key(cancel));
assert!(app.confirm.is_none(), "{cancel:?} closes");
assert!(rx.try_recv().is_err(), "{cancel:?} must not delete");
}
}
#[test]
fn capture_delete_confirmation_renders_its_prompt() {
let (mut app, _rx) = app();
app.library.update(captures_listing()); app.library.update(captures_listing());
app.library.last();
let _ = app.dispatch(Action::LibraryDeleteNode); let _ = app.dispatch(Action::LibraryDeleteNode);
match rx.try_recv() {
let backend = ratatui::backend::TestBackend::new(80, 24); Ok(MessageFromUi::DeleteNode { path }) => {
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); assert_eq!(path, "/crabidy/mix/album");
terminal.draw(|f| app.render(f)).expect("draw app");
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
} }
text.push('\n'); other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
} }
assert!(
text.contains("delete artist - song? [y/N]"),
"prompt line visible"
);
} }
#[test] #[test]
@ -1368,6 +1543,8 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}; };
let listing = |downloadable| LibraryNode { let listing = |downloadable| LibraryNode {
tracks: vec![track.clone()], tracks: vec![track.clone()],
@ -1426,6 +1603,8 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}], }],
resolving: false, resolving: false,
} }
@ -1461,9 +1640,9 @@ mod tests {
assert!( assert!(
matches!( matches!(
&input.purpose, &input.purpose,
InputPurpose::Capture { path, download: true } if path == "/queues/current" InputPurpose::Capture { path, download: true } if path == "/crabidy/current"
), ),
"queue W download-captures /queues/current" "queue W download-captures /crabidy/current"
); );
// Submitting sends a download capture of the live queue. // Submitting sends a download capture of the live queue.
@ -1475,7 +1654,7 @@ mod tests {
name, name,
download, download,
}) => { }) => {
assert_eq!(path, "/queues/current"); assert_eq!(path, "/crabidy/current");
assert_eq!(name, "party"); assert_eq!(name, "party");
assert!(download); assert!(download);
} }

View File

@ -1,5 +1,6 @@
use std::{ops::Div, time::Duration}; use std::{ops::Div, time::Duration};
#[cfg(feature = "notifications")]
use notify_rust::Notification; use notify_rust::Notification;
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition}; use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
@ -84,30 +85,7 @@ impl NowPlaying {
} }
pub fn update_track(&mut self, active: Option<Track>) { pub fn update_track(&mut self, active: Option<Track>) {
if let Some(track) = &active { if let Some(track) = &active {
let body = if let Some(ref album) = track.album { notify_now_playing(track);
format!(
"{} by {}\n\n{} ({})",
track.title,
track.artist,
album.title,
// FIXME: get out year and format differently if it's missing
album.release_date()
)
} else {
format!("{} by {}", track.title, track.artist,)
};
// A missing notification daemon must not crash the TUI.
// The explicit appname keeps notification-daemon rules
// (e.g. mako `app-name=` criteria) stable even if the
// binary is renamed or wrapped.
if let Err(err) = Notification::new()
.appname("crabidy")
.summary("Now playing")
.body(&body)
.show()
{
tracing::debug!("could not show desktop notification: {err}");
}
} }
self.track = active; self.track = active;
} }
@ -122,6 +100,11 @@ impl NowPlaying {
pub fn set_spectrum_enabled(&mut self, enabled: bool) { pub fn set_spectrum_enabled(&mut self, enabled: bool) {
self.spectrum_enabled = enabled; self.spectrum_enabled = enabled;
} }
/// Shows or hides the spectrum row (the `v` keybinding). The server
/// keeps streaming the bars; this only gates rendering.
pub fn toggle_spectrum(&mut self) {
self.spectrum_enabled = !self.spectrum_enabled;
}
/// Reflects the server's mute state. /// Reflects the server's mute state.
pub fn update_mute(&mut self, muted: bool) { pub fn update_mute(&mut self, muted: bool) {
self.muted = muted; self.muted = muted;
@ -273,6 +256,39 @@ impl NowPlaying {
} }
} }
/// Shows the desktop "now playing" notification. A missing notification daemon
/// must not crash the TUI, so a failure is only logged. The explicit appname
/// keeps notification-daemon rules (e.g. mako `app-name=` criteria) stable even
/// if the binary is renamed or wrapped.
#[cfg(feature = "notifications")]
fn notify_now_playing(track: &Track) {
let body = if let Some(ref album) = track.album {
format!(
"{} by {}\n\n{} ({})",
track.title,
track.artist,
album.title,
// FIXME: get out year and format differently if it's missing
album.release_date()
)
} else {
format!("{} by {}", track.title, track.artist)
};
if let Err(err) = Notification::new()
.appname("crabidy")
.summary("Now playing")
.body(&body)
.show()
{
tracing::debug!("could not show desktop notification: {err}");
}
}
/// Built without the `notifications` feature: nothing to show
/// (architecture/build-features.md D1).
#[cfg(not(feature = "notifications"))]
fn notify_now_playing(_track: &Track) {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -293,6 +309,8 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}), }),
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
@ -373,6 +391,19 @@ mod tests {
); );
} }
#[test]
fn toggle_spectrum_hides_then_shows_the_bars() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
// `v` hides the bars…
pane.toggle_spectrum();
assert!(!rendered_rows(&pane).iter().any(|r| r.contains('█')));
// …and again brings them back.
pane.toggle_spectrum();
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
}
/// The position can overrun a stale or wrong duration (streams, /// The position can overrun a stale or wrong duration (streams,
/// hand-written track files); the gauge must clamp instead of hitting /// hand-written track files); the gauge must clamp instead of hitting
/// ratatui's `ratio should be between 0 and 1` panic. /// ratatui's `ratio should be between 0 and 1` panic.

View File

@ -110,6 +110,7 @@ impl Queue {
is_deletable: false, is_deletable: false,
is_downloadable: false, is_downloadable: false,
is_skipped: t.is_skipped, is_skipped: t.is_skipped,
is_captured: t.is_captured,
}) })
.collect(); .collect();
@ -233,6 +234,8 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}) })
.collect(), .collect(),
resolving, resolving,

View File

@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde}; use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
#[derive(ClapSerde, Serialize, Debug)] #[derive(ClapSerde, Serialize, Debug)]
@ -8,6 +10,140 @@ pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
} }
/// The on-disk path of a client config file:
/// `dirs::config_dir()/crabidy/<file_name>`.
pub fn config_path(file_name: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("crabidy").join(file_name))
}
/// Loads a client config, writing a defaults file on first run.
///
/// This is the no-subcommand read path, replacing the general
/// `crabidy_core::init_config` for the clap-derive binaries: it reads the
/// file (or writes defaults when absent) using the same TOML serialization,
/// but never calls `merge_clap` — argv is parsed by `cbd_cli::TuiCli`, and
/// the flags are applied afterwards with [`apply_overrides`]. A missing
/// config directory falls back to the built-in defaults without writing.
pub fn load_first_run(file_name: &str) -> Config {
let Some(path) = config_path(file_name) else {
return Config::default();
};
load_first_run_at(&path)
}
/// [`load_first_run`] against an explicit path (the testable core).
pub fn load_first_run_at(path: &Path) -> Config {
if let Some(parent) = path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
eprintln!(
"could not create config directory {}: {err}",
parent.display()
);
return Config::default();
}
}
if !path.is_file() {
let config = Config::default();
match toml::to_string_pretty(&config) {
Ok(text) => {
if let Err(err) = std::fs::write(path, text) {
eprintln!("could not write config {}: {err}", path.display());
}
}
Err(err) => eprintln!("could not serialize config: {err}"),
}
return config;
}
load_existing(path).unwrap_or_default()
}
/// Reads an existing config file into a [`Config`], returning `None` on a
/// missing/unreadable/unparsable file (the caller falls back to defaults).
fn load_existing(path: &Path) -> Option<Config> {
let text = std::fs::read_to_string(path).ok()?;
match toml::from_str::<<Config as ClapSerde>::Opt>(&text) {
Ok(opt) => Some(Config::from(opt)),
Err(err) => {
eprintln!("invalid config {}: {err}", path.display());
None
}
}
}
/// Applies CLI overrides onto a loaded config: a provided flag wins over the
/// file value; an omitted flag leaves the file value in place
/// (architecture/cli.md D2).
pub fn apply_overrides(
config: &mut Config,
address: Option<String>,
user: Option<String>,
password: Option<String>,
spectrum: Option<bool>,
) {
if let Some(address) = address {
config.server.address = address;
}
if let Some(user) = user {
config.server.user = user;
}
if let Some(password) = password {
config.server.password = password;
}
if let Some(spectrum) = spectrum {
config.server.spectrum = spectrum;
}
}
/// Writes `config` back to `file_name` (same TOML shape as [`load_first_run`]
/// wrote), creating the config directory if missing. Returns the path
/// written. The password is stored in plaintext — keep the file private.
pub fn store(file_name: &str, config: &Config) -> Result<PathBuf, String> {
let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?;
store_at(&path, config)?;
Ok(path)
}
/// [`store`] against an explicit path (the testable core).
pub fn store_at(path: &Path, config: &Config) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
}
let text =
toml::to_string_pretty(config).map_err(|err| format!("cannot serialize config: {err}"))?;
std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display()))
}
/// The `auth` subcommand writer: loads `file_name` (or defaults), sets the
/// basic-auth `user` (role name) and cleartext `password` (and `address`
/// when given), preserving other fields, and writes it back.
pub fn write_auth(
file_name: &str,
user: &str,
password: &str,
address: Option<&str>,
) -> Result<PathBuf, String> {
let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?;
write_auth_at(&path, user, password, address)?;
Ok(path)
}
/// [`write_auth`] against an explicit path (the testable core).
pub fn write_auth_at(
path: &Path,
user: &str,
password: &str,
address: Option<&str>,
) -> Result<(), String> {
let mut config = load_existing(path).unwrap_or_default();
config.server.user = user.to_string();
config.server.password = password.to_string();
if let Some(address) = address {
config.server.address = address.to_string();
}
store_at(path, &config)
}
#[derive(ClapSerde, Serialize, Debug)] #[derive(ClapSerde, Serialize, Debug)]
pub struct ServerConfig { pub struct ServerConfig {
/// Server address /// Server address
@ -34,3 +170,69 @@ pub struct ServerConfig {
#[clap(long)] #[clap(long)]
pub spectrum: bool, pub spectrum: bool,
} }
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn first_run_writes_a_defaults_file_and_reloads_it() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
assert!(!path.is_file());
let config = load_first_run_at(&path);
// Defaults written on first run.
assert!(path.is_file());
assert_eq!(config.server.address, "http://127.0.0.1:50051");
assert_eq!(config.server.user, "");
assert!(config.server.spectrum);
// Reloading reads the file (no second write needed).
let reloaded = load_first_run_at(&path);
assert_eq!(reloaded.server.address, config.server.address);
}
#[test]
fn a_provided_flag_overrides_the_file_and_omitted_flags_do_not() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
let mut config = load_first_run_at(&path);
apply_overrides(
&mut config,
Some("http://pi:50051".to_string()),
None,
None,
Some(false),
);
assert_eq!(config.server.address, "http://pi:50051");
// user was omitted: falls back to the file value.
assert_eq!(config.server.user, "");
assert!(!config.server.spectrum);
}
#[test]
fn write_auth_round_trips_user_password_address() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
// Seed a defaults file, then set credentials.
load_first_run_at(&path);
write_auth_at(&path, "queue-owner", "s3cret", Some("http://pi:50051")).expect("write auth");
let reloaded = load_first_run_at(&path);
assert_eq!(reloaded.server.user, "queue-owner");
assert_eq!(reloaded.server.password, "s3cret");
assert_eq!(reloaded.server.address, "http://pi:50051");
// spectrum (an unrelated field) is preserved at its default.
assert!(reloaded.server.spectrum);
}
#[test]
fn write_auth_without_address_keeps_the_existing_one() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cbd-tui.toml");
write_auth_at(&path, "owner", "pw", Some("http://a:1")).expect("first");
write_auth_at(&path, "owner", "pw2", None).expect("second");
let reloaded = load_first_run_at(&path);
assert_eq!(reloaded.server.password, "pw2");
assert_eq!(reloaded.server.address, "http://a:1");
}
}

View File

@ -285,9 +285,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
// The overlays are strictly modal: while one is open, // The overlays are strictly modal: while one is open,
// keys answer it and the bindings table (including // keys answer it and the bindings table (including
// quit) is unreachable. // quit) is unreachable.
if app.confirm.is_some() { if app.search.is_some() {
app.handle_confirm_key(key);
} else if app.search.is_some() {
app.handle_search_key(key); app.handle_search_key(key);
} else if app.input.is_some() { } else if app.input.is_some() {
app.handle_input_key(key); app.handle_input_key(key);

View File

@ -1,11 +1,19 @@
//! The standalone TUI binary: file-based tracing (the terminal belongs //! The standalone TUI binary: a clap-derive CLI ([`cbd_cli::TuiCli`]). With
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives //! no subcommand it loads the client config (writing defaults on first run),
//! in the library so the bundled `cbd` binary can host it too //! applies the `--address/--user/--password/--spectrum` overrides, and runs
//! (architecture/cbd-bundle.md D1). //! the TUI — exactly as before. Subcommands cover `auth` (write credentials
//! into the config), remote control (`library`/`queue`/`global`), and shell
//! completions. All client logic lives in the library so the bundled `cbd`
//! binary can host it too (architecture/cbd-bundle.md D1).
use std::sync::OnceLock; use std::sync::OnceLock;
use cbd_tui::config::Config; use cbd_cli::{RemoteCmd, TuiCli, TuiCommand};
use cbd_tui::config::{self, Config};
use clap::{CommandFactory, Parser};
/// The config file name for the standalone TUI.
const CONFIG_FILE: &str = "cbd-tui.toml";
static CONFIG: OnceLock<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
@ -43,7 +51,80 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _log_guard = init_tracing(); let cli = TuiCli::parse();
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml")); match cli.command {
cbd_tui::run(config).await // No subcommand: load config, apply overrides, run the TUI.
None => {
let _log_guard = init_tracing();
let mut config = config::load_first_run(CONFIG_FILE);
config::apply_overrides(
&mut config,
cli.remote.address,
cli.remote.user,
cli.remote.password,
cli.spectrum,
);
let config = CONFIG.get_or_init(|| config);
cbd_tui::run(config).await
}
// Subcommands are one-shot CLI actions; a failure prints a short
// message and exits non-zero.
Some(command) => {
if let Err(err) = run_command(&cli.remote, command).await {
eprintln!("error: {err}");
std::process::exit(1);
}
Ok(())
}
}
}
/// Dispatches a TUI subcommand.
async fn run_command(
remote: &cbd_cli::RemoteArgs,
command: TuiCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
TuiCommand::Auth(args) => {
let password = args
.password
.ok_or("missing password (pass it as an argument)")?;
let path = config::write_auth(
CONFIG_FILE,
args.role.user_name(),
&password,
args.address.as_deref(),
)?;
println!(
"wrote credentials for {} to {}",
args.role.user_name(),
path.display()
);
Ok(())
}
TuiCommand::Library(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
}
TuiCommand::Queue(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
}
TuiCommand::Global(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
}
TuiCommand::Completions(args) => {
cbd_cli::print_completions(args.shell, &mut TuiCli::command(), "cbd-tui");
Ok(())
}
}
}
/// Resolves the connection for a remote command: the CLI flags win over the
/// client config, which supplies the fallback (address and credentials).
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
let config = config::load_first_run(CONFIG_FILE);
cbd_cli::Connection {
address: remote.address.clone().unwrap_or(config.server.address),
user: remote.user.clone().unwrap_or(config.server.user),
password: remote.password.clone().unwrap_or(config.server.password),
}
} }

View File

@ -86,14 +86,14 @@ pub struct RpcClient {
/// Whether a library listing may be served from the session cache. /// Whether a library listing may be served from the session cache.
/// ///
/// The server-side folder providers mutate behind the client's back — /// The server-side folder providers mutate behind the client's back —
/// captures finish (`W`), queues get saved (`w`), bookmarks appear, /// saves and captures appear under `/crabidy` (`w`/`W`), files change on
/// files change on disk — so a cached listing turns freshly captured /// disk under `/fs`, and `/orphans` is recomputed from the store on every
/// content invisible until a restart. Their listings are cheap local /// visit — so a cached listing turns freshly captured content (or a changed
/// directory walks on the server; always refetch them. Remote provider /// orphan set) invisible until a restart. Their listings are cheap local
/// nodes (tidal, youtube) keep the cache that makes back-navigation /// walks on the server; always refetch them. Remote provider nodes (tidal,
/// instant. /// youtube) keep the cache that makes back-navigation instant.
fn is_cacheable(path: &str) -> bool { fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"]; const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
!MUTABLE_ROOTS.iter().any(|root| { !MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/')) path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
}) })
@ -392,14 +392,15 @@ mod tests {
#[test] #[test]
fn mutable_provider_listings_are_never_cached() { fn mutable_provider_listings_are_never_cached() {
// Freshly captured/saved content must show up on the next visit // Freshly captured/saved content must show up on the next visit
// (a cached /captures hid new captures until a TUI restart). // (a cached /crabidy hid new saves until a TUI restart).
for path in [ for path in [
"/captures", "/crabidy",
"/captures/faves", "/crabidy/faves",
"/queues", "/crabidy/current",
"/queues/road trip", "/crabidy/road trip",
"/bookmarks/b",
"/fs/music", "/fs/music",
"/orphans",
"/orphans/stray.flac",
] { ] {
assert!(!is_cacheable(path), "{path}"); assert!(!is_cacheable(path), "{path}");
} }
@ -409,6 +410,7 @@ mod tests {
} }
// …and prefix look-alikes are not swept up. // …and prefix look-alikes are not swept up.
assert!(is_cacheable("/fsdy")); assert!(is_cacheable("/fsdy"));
assert!(is_cacheable("/queuestore")); assert!(is_cacheable("/crabidystore"));
assert!(is_cacheable("/orphansaurus"));
} }
} }

View File

@ -25,6 +25,8 @@ web-sys = { workspace = true, features = [
"KeyboardEvent", "KeyboardEvent",
"Location", "Location",
"Performance", "Performance",
"ScrollIntoViewOptions",
"ScrollLogicalPosition",
"Storage", "Storage",
"Window", "Window",
] } ] }

View File

@ -1,8 +1,7 @@
# cbd-web — the browser client # cbd-web — the browser client
A [Leptos](https://leptos.dev) client-side WASM app with the same A [Leptos](https://leptos.dev) client-side WASM app with the same
functionality as `cbd-tui`, served by `crabidy-server` itself. See functionality as `cbd-tui`, served by `crabidy-server` itself.
`architecture/web-client.md` for the design.
## How it works ## How it works
@ -10,8 +9,8 @@ functionality as `cbd-tui`, served by `crabidy-server` itself. See
generated client and proto types the TUI uses (`crabidy-core`). No generated client and proto types the TUI uses (`crabidy-core`). No
second API surface — feature parity is structural. The server wraps second API surface — feature parity is structural. The server wraps
its existing gRPC service in `tonic-web`, so the browser and the TUI its existing gRPC service in `tonic-web`, so the browser and the TUI
hit identical `/crabidy.v1.CrabidyService/…` paths, and the role hit identical `/crabidy.v1.CrabidyService/…` paths, and the same role
auth layer (`architecture/roles-auth.md`) gates both. auth layer gates both.
- **Serving**: the built bundle (`cbd-web/dist`) is embedded into - **Serving**: the built bundle (`cbd-web/dist`) is embedded into
`crabidy-server` at compile time behind the default-on `web-ui` `crabidy-server` at compile time behind the default-on `web-ui`
feature and served as the fallback route on port 50051. gRPC and feature and served as the fallback route on port 50051. gRPC and
@ -27,13 +26,12 @@ functionality as `cbd-tui`, served by `crabidy-server` itself. See
## Functionality ## Functionality
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click), Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click),
marks, create/rename/delete nodes (`%`/`e`/`d`, with the capture-delete marks, create/rename/delete nodes (`%`/`e`/`d`), bookmark and capture
`y/N` confirmation), bookmark and capture (`w`/`W`, with live progress (`w`/`W`, with live progress lines and skipped-track marking), the full queue
lines and skipped-track marking), the full queue and playback controls, and playback controls, volume, shuffle/repeat, and a `?` help overlay listing
volume, shuffle/repeat, and a `?` help overlay listing the keys. Keys the keys. Keys mirror the TUI; every key also has a clickable control. A
mirror the TUI; every key also has a clickable control. A light/dark light/dark theme follows the OS and can be toggled (persisted). The accent
theme follows the OS and can be toggled (persisted). The accent color color is the crab orange-red.
is the crab orange-red.
When the server requires credentials, a login form collects the role When the server requires credentials, a login form collects the role
(`owner` / `queue-owner` / `queue-appender`) and password; they are (`owner` / `queue-owner` / `queue-appender`) and password; they are
@ -54,9 +52,10 @@ cargo build -p crabidy-server # embeds cbd-web/dist
mold linker, which `rust-lld` (the wasm linker) cannot parse. mold linker, which `rust-lld` (the wasm linker) cannot parse.
Building `crabidy-server` without a `cbd-web/dist` present is fine — it Building `crabidy-server` without a `cbd-web/dist` present is fine — it
embeds a placeholder page telling you to run `build-web`. Build the embeds a placeholder page telling you to run `build-web`. To drop the web
server `--no-default-features` to drop the web client (and the client (and the `tonic-web` layer) entirely, build the server without its
`tonic-web` layer) entirely. `web-ui` cargo feature — e.g. `--no-default-features --features
all-providers,opus,spectrum`; see `docs/src/build-features.md`.
## Dev loop ## Dev loop

View File

@ -15,8 +15,8 @@ use leptos::task::spawn_local;
use crate::keymap::{self, Action}; use crate::keymap::{self, Action};
use crate::rpc::Rpc; use crate::rpc::Rpc;
use crate::state::{ use crate::state::{
delete_needs_confirmation, format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
Focus, LibraryPane, NamePurpose, QueueCursor, UiItemKind, NamePurpose, QueueCursor, UiItemKind,
}; };
const VOLUME_STEP: f32 = 0.1; const VOLUME_STEP: f32 = 0.1;
@ -65,6 +65,27 @@ fn apply_theme(theme: &str) {
} }
} }
/// Scrolls the selected row of a pane back into view after a keyboard move,
/// so the cursor never drifts out of the scroll box (the click handlers
/// need no help — the pointer is already on-screen). `pane_selector` picks
/// the pane (`.pane.library` / `.pane.queue`); we then find its `.selected`
/// row. Deferred to the next animation frame so the freshly-rendered row is
/// in the DOM. `block: nearest` scrolls the minimum amount — an already
/// visible row does not move.
fn scroll_selected_into_view(pane_selector: &'static str) {
request_animation_frame(move || {
let selector = format!("{pane_selector} .list li.selected");
if let Some(el) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector(&selector).ok().flatten())
{
let opts = web_sys::ScrollIntoViewOptions::new();
opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
el.scroll_into_view_with_scroll_into_view_options(&opts);
}
});
}
// ---- the store --------------------------------------------------------- // ---- the store ---------------------------------------------------------
/// Every signal the components share. `Copy` so closures capture it /// Every signal the components share. `Copy` so closures capture it
@ -73,6 +94,16 @@ fn apply_theme(theme: &str) {
struct Store { struct Store {
connected: RwSignal<bool>, connected: RwSignal<bool>,
needs_login: RwSignal<bool>, needs_login: RwSignal<bool>,
/// Whether the open login dialog may be dismissed to keep the
/// unauthenticated fallback role (true) or credentials are required
/// because the server denied anonymous access (false).
guest_ok: RwSignal<bool>,
/// Set once the proactive "auth is enabled" prompt has been shown, so
/// stream reconnects do not re-open it after the user dismissed it.
login_prompted: RwSignal<bool>,
/// Whether the server has auth configured (from `Init.auth_enabled`),
/// so the top bar can offer a login only where it is meaningful.
auth_enabled: RwSignal<bool>,
queue: RwSignal<Vec<Track>>, queue: RwSignal<Vec<Track>>,
queue_pos: RwSignal<u32>, queue_pos: RwSignal<u32>,
resolving: RwSignal<bool>, resolving: RwSignal<bool>,
@ -101,6 +132,9 @@ impl Store {
Self { Self {
connected: RwSignal::new(false), connected: RwSignal::new(false),
needs_login: RwSignal::new(false), needs_login: RwSignal::new(false),
guest_ok: RwSignal::new(false),
login_prompted: RwSignal::new(false),
auth_enabled: RwSignal::new(false),
queue: RwSignal::new(Vec::new()), queue: RwSignal::new(Vec::new()),
queue_pos: RwSignal::new(0), queue_pos: RwSignal::new(0),
resolving: RwSignal::new(false), resolving: RwSignal::new(false),
@ -132,6 +166,7 @@ impl Store {
fn fail(&self, status: tonic::Status) { fn fail(&self, status: tonic::Status) {
if status.code() == tonic::Code::Unauthenticated { if status.code() == tonic::Code::Unauthenticated {
self.needs_login.set(true); self.needs_login.set(true);
self.guest_ok.set(false);
self.dialog.set(Some(Dialog::Login)); self.dialog.set(Some(Dialog::Login));
return; return;
} }
@ -353,14 +388,14 @@ impl Store {
} }
} }
Action::LibraryDeleteNode => { Action::LibraryDeleteNode => {
if let Some((path, title)) = // Deleting under `/crabidy` drops the metadata toml only; the
// shared store audio survives and resurfaces under
// `/orphans`, so there is nothing to confirm — same as the
// TUI.
if let Some((path, _title)) =
self.library.with_untracked(LibraryPane::selected_deletable) self.library.with_untracked(LibraryPane::selected_deletable)
{ {
if delete_needs_confirmation(&path) { self.delete_node(path);
self.dialog.set(Some(Dialog::ConfirmDelete { path, title }));
} else {
self.delete_node(path);
}
} }
} }
Action::LibraryCaptureNode => { Action::LibraryCaptureNode => {
@ -519,6 +554,22 @@ fn run_stream(store: Store) {
if let Some(position) = init.position { if let Some(position) = init.position {
store.apply(StreamUpdate::Position(position)); store.apply(StreamUpdate::Position(position));
} }
// We connected — as the unauthenticated fallback
// role if we sent no credentials. Record whether
// the server has auth at all (drives the top-bar
// login button), then, when it does and the user
// has none stored, offer a login once — dismissible
// so they can stay on the fallback role.
store.auth_enabled.set(init.auth_enabled);
let no_stored_creds = load_pref("user").unwrap_or_default().is_empty();
if init.auth_enabled
&& no_stored_creds
&& !store.login_prompted.get_untracked()
{
store.login_prompted.set(true);
store.guest_ok.set(true);
store.dialog.set(Some(Dialog::Login));
}
} }
Err(status) => store.fail(status), Err(status) => store.fail(status),
} }
@ -543,6 +594,7 @@ fn run_stream(store: Store) {
} }
Err(status) if status.code() == tonic::Code::Unauthenticated => { Err(status) if status.code() == tonic::Code::Unauthenticated => {
store.needs_login.set(true); store.needs_login.set(true);
store.guest_ok.set(false);
store.dialog.set(Some(Dialog::Login)); store.dialog.set(Some(Dialog::Login));
return; return;
} }
@ -641,6 +693,47 @@ fn TopBar(store: Store) -> impl IntoView {
{move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }} {move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }}
</span> </span>
<span class="topbar-actions"> <span class="topbar-actions">
{move || {
// A login is only meaningful where the server has auth
// (offer "log in"); once credentials are stored, offer
// "log out" to drop back to the guest/fallback role.
let logged_in = !load_pref("user").unwrap_or_default().is_empty();
(store.auth_enabled.get() || logged_in)
.then(|| {
if logged_in {
view! {
<button
class="ghost"
title="log out (return to the guest role)"
on:click=move |_| {
save_pref("user", "");
save_pref("password", "");
if let Some(window) = web_sys::window() {
let _ = window.location().reload();
}
}
>
"log out"
</button>
}
.into_any()
} else {
view! {
<button
class="ghost"
title="log in"
on:click=move |_| {
store.guest_ok.set(true);
store.dialog.set(Some(Dialog::Login));
}
>
"log in"
</button>
}
.into_any()
}
})
}}
<button class="ghost" title="cycle theme (auto/light/dark)" on:click=cycle_theme> <button class="ghost" title="cycle theme (auto/light/dark)" on:click=cycle_theme>
"" ""
</button> </button>
@ -661,6 +754,13 @@ fn LibraryView(store: Store) -> impl IntoView {
let focused = move || store.focus.get() == Focus::Library; let focused = move || store.focus.get() == Focus::Library;
let library = store.library; let library = store.library;
// Keep the keyboard cursor visible: whenever the library cursor (or its
// listing) changes, scroll the selected row back into the scroll box.
Effect::new(move |_| {
let _ = library.with(|p| p.selected);
scroll_selected_into_view(".pane.library");
});
let toolbar = move || { let toolbar = move || {
let pane = library.get(); let pane = library.get();
let selection = pane.selected_item(); let selection = pane.selected_item();
@ -816,6 +916,14 @@ fn LibraryView(store: Store) -> impl IntoView {
#[component] #[component]
fn QueueView(store: Store) -> impl IntoView { fn QueueView(store: Store) -> impl IntoView {
let focused = move || store.focus.get() == Focus::Queue; let focused = move || store.focus.get() == Focus::Queue;
// Keep the keyboard cursor visible. The cursor is its own signal, so this
// fires on moves but not on every stream-driven queue refresh.
Effect::new(move |_| {
let _ = store.queue_cursor.get();
scroll_selected_into_view(".pane.queue");
});
view! { view! {
<section <section
class="pane queue" class="pane queue"
@ -960,7 +1068,7 @@ fn Transport(store: Store) -> impl IntoView {
</span> </span>
<div class="progress"> <div class="progress">
<span class="time"> <span class="time">
{move || format_seconds(store.position.get().position)} {move || format_seconds(store.position.get().position / 1000)}
</span> </span>
<div class="gauge"> <div class="gauge">
<div <div
@ -980,7 +1088,7 @@ fn Transport(store: Store) -> impl IntoView {
></div> ></div>
</div> </div>
<span class="time"> <span class="time">
{move || format_seconds(store.position.get().duration)} {move || format_seconds(store.position.get().duration / 1000)}
</span> </span>
</div> </div>
</div> </div>
@ -1014,9 +1122,6 @@ fn Dialogs(store: Store) -> impl IntoView {
Dialog::Name { purpose, buffer } => { Dialog::Name { purpose, buffer } => {
view! { <NameDialog store=store purpose=purpose buffer=buffer /> }.into_any() view! { <NameDialog store=store purpose=purpose buffer=buffer /> }.into_any()
} }
Dialog::ConfirmDelete { path, title } => {
view! { <ConfirmDialog store=store path=path title=title /> }.into_any()
}
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(), Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(), Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(),
}) })
@ -1058,52 +1163,8 @@ fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoVi
} }
} }
#[component]
fn ConfirmDialog(store: Store, path: String, title: String) -> impl IntoView {
let confirm_path = path.clone();
let confirm = move |_| {
store.dialog.set(None);
store.delete_node(confirm_path.clone());
};
// y/N without leaving the keyboard, like the TUI: the overlay grabs
// the keys while it is open.
let key_path = path;
let handle = window_event_listener(leptos::ev::keydown, move |ev| {
if !matches!(
store.dialog.get_untracked(),
Some(Dialog::ConfirmDelete { .. })
) {
return;
}
ev.prevent_default();
store.dialog.set(None);
if matches!(ev.key().as_str(), "y" | "Y") {
store.delete_node(key_path.clone());
}
});
on_cleanup(move || handle.remove());
view! {
<div class="overlay" on:click=move |_| store.dialog.set(None)>
<div class="dialog danger-dialog" on:click=|ev| ev.stop_propagation()>
<p>
"Delete "<strong>{title}</strong>
" from disk (downloaded audio included)?"
</p>
<div class="dialog-actions">
<button class="ghost" on:click=move |_| store.dialog.set(None)>
"cancel (N)"
</button>
<button class="danger" on:click=confirm>"delete (y)"</button>
</div>
</div>
</div>
}
}
#[component] #[component]
fn LoginDialog(store: Store) -> impl IntoView { fn LoginDialog(store: Store) -> impl IntoView {
// The dialog needs no store access: submitting reloads the page.
let _ = store;
let user = RwSignal::new(load_pref("user").unwrap_or_default()); let user = RwSignal::new(load_pref("user").unwrap_or_default());
let password = RwSignal::new(String::new()); let password = RwSignal::new(String::new());
let submit = move |ev: leptos::ev::SubmitEvent| { let submit = move |ev: leptos::ev::SubmitEvent| {
@ -1115,10 +1176,22 @@ fn LoginDialog(store: Store) -> impl IntoView {
let _ = window.location().reload(); let _ = window.location().reload();
} }
}; };
// When the server granted an unauthenticated fallback role we are
// already connected under it, so the prompt is skippable; when it
// denied anonymous access, credentials are the only way in.
let guest_ok = store.guest_ok;
let dismiss = move |_| store.dialog.set(None);
let label = move || {
if guest_ok.get() {
"Log in for more access, or continue as guest (architecture/roles-auth.md)"
} else {
"This server requires credentials (architecture/roles-auth.md)"
}
};
view! { view! {
<div class="overlay"> <div class="overlay">
<form class="dialog" on:submit=submit> <form class="dialog" on:submit=submit>
<label>"This server requires credentials (architecture/roles-auth.md)"</label> <label>{label}</label>
<input <input
type="text" type="text"
placeholder="role: owner | queue-owner | queue-appender" placeholder="role: owner | queue-owner | queue-appender"
@ -1133,6 +1206,17 @@ fn LoginDialog(store: Store) -> impl IntoView {
on:input=move |ev| password.set(event_target_value(&ev)) on:input=move |ev| password.set(event_target_value(&ev))
/> />
<div class="dialog-actions"> <div class="dialog-actions">
{move || {
guest_ok
.get()
.then(|| {
view! {
<button type="button" on:click=dismiss>
"continue as guest"
</button>
}
})
}}
<button type="submit">"connect"</button> <button type="submit">"connect"</button>
</div> </div>
</form> </form>

View File

@ -52,26 +52,22 @@ pub enum Dialog {
purpose: NamePurpose, purpose: NamePurpose,
buffer: String, buffer: String,
}, },
/// The capture-delete confirmation (architecture/capture-deletion.md). /// Credentials form. Shown on `UNAUTHENTICATED` responses (creds
ConfirmDelete { path: String, title: String }, /// required), and proactively — but dismissible — on first connect
/// Credentials form, shown on `UNAUTHENTICATED` responses. /// when the server reports auth is enabled and we hold none.
Login, Login,
/// The `?` key binding overlay. /// The `?` key binding overlay.
Help, Help,
} }
/// Whether deleting `path` needs the y/N confirmation — same rule as
/// the TUI: captures hold downloaded audio, everything else deletable
/// is cheap to recreate.
pub fn delete_needs_confirmation(path: &str) -> bool {
path == "/captures" || path.starts_with("/captures/")
}
/// Whether a library listing may be cached client-side — same rule as /// Whether a library listing may be cached client-side — same rule as
/// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate /// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate
/// behind the client's back and are cheap to re-list. /// behind the client's back and are cheap to re-list. `/crabidy` (saves and
/// captures), `/fs` (files on disk), and `/orphans` (recomputed from the
/// store on every visit) all change server-side, so their listings are
/// always refetched.
pub fn is_cacheable(path: &str) -> bool { pub fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"]; const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
!MUTABLE_ROOTS.iter().any(|root| { !MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/')) path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
}) })
@ -340,9 +336,17 @@ impl CaptureBoard {
} }
} }
/// `mm:ss` for progress and duration displays. /// `mm:ss`, or `h:mm:ss` once past an hour — matching the TUI's now-playing
/// clock so minutes zero-pad and roll into hours instead of counting past 60.
pub fn format_seconds(total: u32) -> String { pub fn format_seconds(total: u32) -> String {
format!("{}:{:02}", total / 60, total % 60) let secs = total % 60;
let mins = (total / 60) % 60;
let hours = total / 3600;
if hours > 0 {
format!("{hours}:{mins:02}:{secs:02}")
} else {
format!("{mins:02}:{secs:02}")
}
} }
/// The now-playing line for a track, `artist - title` falling back to /// The now-playing line for a track, `artist - title` falling back to
@ -376,12 +380,15 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}) })
.collect(), .collect(),
is_queable: true, is_queable: true,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false, tracks_deletable: false,
is_captured: false,
} }
} }
@ -451,21 +458,23 @@ mod tests {
); );
} }
#[test]
fn capture_deletes_need_confirmation_cheap_deletes_do_not() {
assert!(delete_needs_confirmation("/captures/mix"));
assert!(delete_needs_confirmation("/captures/mix/a.cbd-track.toml"));
assert!(!delete_needs_confirmation("/queues/roadtrip"));
assert!(!delete_needs_confirmation("/tidal/search/abba"));
assert!(!delete_needs_confirmation("/capturesque"));
}
#[test] #[test]
fn mutable_roots_are_never_cacheable() { fn mutable_roots_are_never_cacheable() {
for path in ["/captures", "/queues/x", "/bookmarks", "/fs/music"] { for path in [
"/crabidy",
"/crabidy/faves",
"/fs/music",
"/orphans",
"/orphans/stray.flac",
] {
assert!(!is_cacheable(path), "{path}"); assert!(!is_cacheable(path), "{path}");
} }
for path in ["/tidal/playlists", "/youtube/search", "/capturesque"] { for path in [
"/tidal/playlists",
"/youtube/search",
"/crabidystore",
"/orphansaurus",
] {
assert!(is_cacheable(path), "{path}"); assert!(is_cacheable(path), "{path}");
} }
} }
@ -510,8 +519,11 @@ mod tests {
#[test] #[test]
fn time_formatting_is_mm_ss() { fn time_formatting_is_mm_ss() {
assert_eq!(format_seconds(0), "0:00"); assert_eq!(format_seconds(0), "00:00");
assert_eq!(format_seconds(61), "1:01"); assert_eq!(format_seconds(61), "01:01");
assert_eq!(format_seconds(3599), "59:59"); assert_eq!(format_seconds(3599), "59:59");
// Past an hour it rolls into h:mm:ss instead of counting past 60 min.
assert_eq!(format_seconds(3600), "1:00:00");
assert_eq!(format_seconds(3661), "1:01:01");
} }
} }

View File

@ -76,11 +76,6 @@ button {
} }
} }
&.danger {
background: var(--danger);
color: var(--on-accent);
}
&.ghost.danger { &.ghost.danger {
background: transparent; background: transparent;
color: var(--danger); color: var(--danger);
@ -395,10 +390,6 @@ input {
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
} }
&.danger-dialog {
border-inline-start: 4px solid var(--danger);
}
} }
.help { .help {

View File

@ -3,12 +3,38 @@ name = "cbd"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# The bundle owns both halves, so it forwards both feature sets
# (architecture/build-features.md D1). `cargo build -p cbd
# --no-default-features --features fs,opus` is a local-files-only bundle.
[features]
default = ["all-providers", "opus", "spectrum", "web-ui", "notifications"]
all-providers = ["crabidy-server/all-providers"]
tidal = ["crabidy-server/tidal"]
youtube = ["crabidy-server/youtube"]
fyyd = ["crabidy-server/fyyd"]
abs = ["crabidy-server/abs"]
soundcloud = ["crabidy-server/soundcloud"]
jamendo = ["crabidy-server/jamendo"]
fs = ["crabidy-server/fs"]
opus = ["crabidy-server/opus"]
spectrum = ["crabidy-server/spectrum"]
web-ui = ["crabidy-server/web-ui"]
notifications = ["cbd-tui/notifications"]
[dependencies] [dependencies]
cbd-tui.workspace = true cbd-cli = { workspace = true, features = ["client"] }
cbd-tui = { workspace = true, default-features = false }
clap.workspace = true
crabidy-core.workspace = true crabidy-core.workspace = true
crabidy-server.workspace = true crabidy-server = { workspace = true, default-features = false }
dirs.workspace = true dirs.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
# Default features only (clap-only) so asset generation stays cheap.
[build-dependencies]
cbd-cli.workspace = true
clap.workspace = true

23
cbd/build.rs Normal file
View File

@ -0,0 +1,23 @@
//! Generates shell completions and a man page for `cbd` from its top-level
//! clap command (architecture/cli.md D7). Written into `OUT_DIR` every build,
//! and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli` is a
//! default-features (clap-only) build-dependency, so this never pulls tonic
//! into ordinary builds.
use std::path::Path;
fn main() {
use clap::CommandFactory;
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
let bin = "cbd";
let command = cbd_cli::CbdCli::command();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
}
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
}
}
}

View File

@ -13,9 +13,15 @@ use std::error::Error;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Duration; use std::time::Duration;
use cbd_tui::config::Config; use cbd_cli::{CbdCli, CbdCommand, RemoteCmd};
use cbd_tui::config::{self, Config};
use clap::{CommandFactory, Parser};
use crabidy_server::cli as server_cli;
use tracing::{info, warn}; use tracing::{info, warn};
/// The config file name for the bundled binary (separate from `cbd-tui.toml`).
const CONFIG_FILE: &str = "cbd.toml";
static CONFIG: OnceLock<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
/// How long to wait for the server socket before giving up. Generous: /// How long to wait for the server socket before giving up. Generous:
@ -25,6 +31,25 @@ const READINESS_DELAY: Duration = Duration::from_millis(500);
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
// `cbd` is the union of the server and client command surfaces
// (architecture/cli.md D2): no subcommand runs the in-process server +
// TUI, exactly as before.
let cli = CbdCli::parse();
match cli.command {
None => run_bundle(cli).await,
Some(command) => {
if let Err(err) = run_command(&cli.remote, command).await {
eprintln!("error: {err}");
std::process::exit(1);
}
Ok(())
}
}
}
/// Runs the bundled server + TUI (the no-subcommand default), unchanged from
/// before save for the clap-based config load and flag overrides.
async fn run_bundle(cli: CbdCli) -> Result<(), Box<dyn Error>> {
// Both halves share one file-based subscriber: the terminal belongs // Both halves share one file-based subscriber: the terminal belongs
// to the TUI, so the server's usual stderr logging would corrupt it. // to the TUI, so the server's usual stderr logging would corrupt it.
let _log_guard = init_tracing(); let _log_guard = init_tracing();
@ -34,7 +59,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single // `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
// shared `address` would force one to follow the other. `cbd` // shared `address` would force one to follow the other. `cbd`
// defaults to localhost, which matches its embedded server. // defaults to localhost, which matches its embedded server.
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd.toml")); let mut config = config::load_first_run(CONFIG_FILE);
config::apply_overrides(
&mut config,
cli.remote.address,
cli.remote.user,
cli.remote.password,
cli.spectrum,
);
let config = CONFIG.get_or_init(|| config);
let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?; let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
let mut server = tokio::spawn(crabidy_server::serve(addr)); let mut server = tokio::spawn(crabidy_server::serve(addr));
@ -49,6 +82,62 @@ async fn main() -> Result<(), Box<dyn Error>> {
cbd_tui::run(config).await cbd_tui::run(config).await
} }
/// Dispatches a `cbd` subcommand: `guard`/`scan` reuse the server logic,
/// `auth` writes `cbd.toml`, and `library`/`queue`/`global` run against a
/// server over gRPC.
async fn run_command(
remote: &cbd_cli::RemoteArgs,
command: CbdCommand,
) -> Result<(), Box<dyn Error>> {
match command {
CbdCommand::Guard(args) => server_cli::guard(args).await,
CbdCommand::Scan(args) => server_cli::scan(args).await,
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
CbdCommand::Features => server_cli::features(),
CbdCommand::Auth(args) => {
let password = args
.password
.ok_or("missing password (pass it as an argument)")?;
let path = config::write_auth(
CONFIG_FILE,
args.role.user_name(),
&password,
args.address.as_deref(),
)?;
println!(
"wrote credentials for {} to {}",
args.role.user_name(),
path.display()
);
Ok(())
}
CbdCommand::Library(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
}
CbdCommand::Queue(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
}
CbdCommand::Global(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
}
CbdCommand::Completions(args) => {
cbd_cli::print_completions(args.shell, &mut CbdCli::command(), "cbd");
Ok(())
}
}
}
/// Resolves the connection for a remote command: the CLI flags win over
/// `cbd.toml`, which supplies the fallback (address and credentials).
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
let config = config::load_first_run(CONFIG_FILE);
cbd_cli::Connection {
address: remote.address.clone().unwrap_or(config.server.address),
user: remote.user.clone().unwrap_or(config.server.user),
password: remote.password.clone().unwrap_or(config.server.password),
}
}
/// Waits until something accepts TCP connections on the TUI's configured /// Waits until something accepts TCP connections on the TUI's configured
/// server address (scheme stripped): the in-process server coming up, or /// server address (scheme stripped): the in-process server coming up, or
/// an already-running standalone one (in which case our `serve` fails /// an already-running standalone one (in which case our `serve` fails

View File

@ -67,6 +67,11 @@ message InitResponse {
float volume = 5; float volume = 5;
bool mute = 6; bool mute = 6;
TrackPosition position = 7; TrackPosition position = 7;
// Whether the server has any credentials configured (the auth on/off
// switch). Reachable anonymously, so a client that connected as the
// unauthenticated fallback role can learn a higher role is available
// and offer a login (architecture/roles-auth.md).
bool auth_enabled = 8;
} }
// Library // Library
@ -254,6 +259,9 @@ message LibraryNodeChild {
bool is_deletable = 6; bool is_deletable = 6;
// This node allows download captures (CaptureLibraryNode with download). // This node allows download captures (CaptureLibraryNode with download).
bool is_downloadable = 7; bool is_downloadable = 7;
// Every track and child node below this child is captured (fully local in
// the content store). Clients mark captured rows.
bool is_captured = 8;
} }
message QueueModifiers { message QueueModifiers {
@ -306,6 +314,14 @@ message Track {
// The track has no playable audio (a capture recorded its source as // The track has no playable audio (a capture recorded its source as
// uncapturable). Clients mark it; playback skips it. // uncapturable). Clients mark it; playback skips it.
bool is_skipped = 6; bool is_skipped = 6;
// Provider-internal id that identifies this item inside its provider,
// independent of the path it was reached by (playlist, search, album).
// Set by the owning provider; empty when unknown. Keys the content store.
string provider_item_id = 7;
// The content store already holds this track (by provider id or, once
// captured, as a store-backed playable). Set at listing time; clients
// mark captured rows. See architecture/crabidy-store.md.
bool is_captured = 8;
} }
message LibraryNode { message LibraryNode {
@ -324,4 +340,8 @@ message LibraryNode {
// This node's listed tracks may be deleted (see DeleteLibraryNode) // This node's listed tracks may be deleted (see DeleteLibraryNode)
// like is_downloadable, tracks inherit the node's flag. // like is_downloadable, tracks inherit the node's flag.
bool tracks_deletable = 9; bool tracks_deletable = 9;
// Every track and child node below this node is captured (fully local in
// the content store). Clients mark captured nodes. See
// architecture/crabidy-store.md.
bool is_captured = 10;
} }

View File

@ -213,6 +213,7 @@ impl LibraryNode {
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false, tracks_deletable: false,
is_captured: false,
} }
} }
} }
@ -231,6 +232,7 @@ impl LibraryNodeChild {
is_editable: false, is_editable: false,
is_deletable: false, is_deletable: false,
is_downloadable: false, is_downloadable: false,
is_captured: false,
} }
} }
} }
@ -365,12 +367,15 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}) })
.collect(), .collect(),
is_queable, is_queable,
is_creatable: false, is_creatable: false,
is_downloadable: false, is_downloadable: false,
tracks_deletable: false, tracks_deletable: false,
is_captured: false,
} }
} }
@ -401,6 +406,8 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}) })
} }
fn get_lib_root(&self) -> LibraryNode { fn get_lib_root(&self) -> LibraryNode {

View File

@ -7,10 +7,48 @@ edition.workspace = true
name = "crabidy-server" name = "crabidy-server"
path = "src/main.rs" path = "src/main.rs"
# Everything is on by default: a plain build is the full server. Tailor a
# smaller binary with `--no-default-features --features …`
# (architecture/build-features.md D1). Feature names are exactly the
# names in `crabidy-server.toml`'s `providers` list (D3), so one
# vocabulary covers the compile-time and the runtime switch.
[features] [features]
# The embedded web client (architecture/web-client.md). On by default; default = ["all-providers", "opus", "spectrum", "web-ui"]
# disable for a headless-only binary without the bundle.
default = ["web-ui"] # Every provider this binary can mount.
all-providers = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"jamendo",
"fs",
]
# Internal marker: every provider feature enables it, so code can ask "is any
# provider compiled in?" — which Cargo features cannot express directly. Only
# a build with no providers at all (legal, see D2) leaves it off.
_any-provider = []
tidal = ["dep:tidaldy", "_any-provider"]
youtube = ["dep:ytdy", "_any-provider"]
fyyd = ["dep:fyyd", "_any-provider"]
abs = ["dep:absdy", "_any-provider"]
soundcloud = ["dep:soundclouddy", "_any-provider"]
jamendo = ["dep:jamendody", "_any-provider"]
# Local files *and* persistent state (D5): the `/fs` mount, the content
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
# persistence, and the `scan` command. Off means the server keeps its
# queue in memory only.
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest", "_any-provider"]
# Ogg-Opus decoding. Off also drops the bundled libopus C build
# (cmake + ninja) and `.opus` from what `scan` indexes (D6).
opus = ["audio-player/opus"]
# The server-side FFT that feeds clients' spectrum bars (D8).
spectrum = ["dep:realfft"]
# The embedded web client (architecture/web-client.md). Disable for a
# headless-only binary without the bundle.
web-ui = ["dep:tonic-web", "dep:include_dir"] web-ui = ["dep:tonic-web", "dep:include_dir"]
[dependencies] [dependencies]
@ -19,23 +57,33 @@ argon2.workspace = true
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true axum.workspace = true
base64.workspace = true base64.workspace = true
blake3 = { workspace = true, optional = true }
clap.workspace = true clap.workspace = true
http.workspace = true http.workspace = true
include_dir = { workspace = true, optional = true } include_dir = { workspace = true, optional = true }
realfft.workspace = true realfft = { workspace = true, optional = true }
tonic-web = { workspace = true, optional = true } tonic-web = { workspace = true, optional = true }
tower.workspace = true tower.workspace = true
audio-player.workspace = true # default-features = false so the server's own `opus` feature decides
# whether the libopus decoder is linked (D1).
audio-player = { workspace = true, default-features = false }
# The `client` feature pulls in the gRPC executor used by the
# library/queue/global subcommands (architecture/cli.md D1/D3).
cbd-cli = { workspace = true, features = ["client"] }
crabidy-core.workspace = true crabidy-core.workspace = true
dirs.workspace = true dirs.workspace = true
flume.workspace = true flume.workspace = true
fsdy.workspace = true absdy = { workspace = true, optional = true }
fsdy = { workspace = true, optional = true }
fyyd = { workspace = true, optional = true }
jamendody = { workspace = true, optional = true }
futures.workspace = true futures.workspace = true
rand.workspace = true rand.workspace = true
reqwest.workspace = true reqwest = { workspace = true, optional = true }
serde.workspace = true serde.workspace = true
soundclouddy = { workspace = true, optional = true }
thiserror.workspace = true thiserror.workspace = true
tidaldy.workspace = true tidaldy = { workspace = true, optional = true }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
toml.workspace = true toml.workspace = true
tokio-stream = { workspace = true, features = ["sync"] } tokio-stream = { workspace = true, features = ["sync"] }
@ -43,7 +91,7 @@ tonic = { workspace = true, features = ["router", "transport", "codegen"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
ytdy.workspace = true ytdy = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
argon2.workspace = true argon2.workspace = true
@ -51,3 +99,9 @@ base64.workspace = true
http.workspace = true http.workspace = true
tempfile.workspace = true tempfile.workspace = true
tower.workspace = true tower.workspace = true
# Default features only (clap-only): generating completions and a man page
# must not drag tonic into ordinary builds (architecture/cli.md D7).
[build-dependencies]
cbd-cli.workspace = true
clap.workspace = true

View File

@ -8,6 +8,32 @@
use std::path::Path; use std::path::Path;
fn main() { fn main() {
generate_cli_assets();
stage_web_bundle();
}
/// Writes shell completions and a man page for `crabidy-server` into `OUT_DIR`
/// every build, and additionally into `$CBD_ASSET_DIR` when set
/// (architecture/cli.md D7). `cbd-cli` is a default-features (clap-only)
/// build-dependency, so this never pulls tonic into ordinary builds.
fn generate_cli_assets() {
use clap::CommandFactory;
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
let bin = "crabidy-server";
let command = cbd_cli::ServerCli::command();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
}
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
}
}
}
/// Stages the web client bundle for embedding (feature `web-ui`).
fn stage_web_bundle() {
// Rerun when the bundle changes (or appears). // Rerun when the bundle changes (or appears).
println!("cargo:rerun-if-changed=../cbd-web/dist"); println!("cargo:rerun-if-changed=../cbd-web/dist");
if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() { if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() {

View File

@ -104,6 +104,13 @@ pub fn hash_password(password: &str) -> Result<String, String> {
pub struct Authenticator { pub struct Authenticator {
/// `(role, PHC hash)` pairs from the config; empty = auth off. /// `(role, PHC hash)` pairs from the config; empty = auth off.
hashes: Vec<(Role, String)>, hashes: Vec<(Role, String)>,
/// The role granted to requests that carry no credentials: the most
/// privileged role left *unguarded* (no hash in the config), or
/// `None` when every role is guarded and anonymous access is denied.
/// Guarding runs from the top down (`AuthSettings::validate`), so
/// this is simply the first role, owner → queue-owner →
/// queue-appender, whose hash is absent.
unauthenticated: Option<Role>,
verified: RwLock<HashMap<String, Role>>, verified: RwLock<HashMap<String, Role>>,
} }
@ -128,8 +135,22 @@ impl Authenticator {
} }
} }
} }
// Anonymous callers inherit the highest role that is *not*
// guarded. A hash that is present but unusable still counts as
// guarded here (fail-closed): that level is neither reachable by
// login nor handed to anonymous callers.
let unauthenticated = if settings.owner.is_none() {
Some(Role::Owner)
} else if settings.queue_owner.is_none() {
Some(Role::QueueOwner)
} else if settings.queue_appender.is_none() {
Some(Role::QueueAppender)
} else {
None
};
Self { Self {
hashes, hashes,
unauthenticated,
verified: RwLock::new(HashMap::new()), verified: RwLock::new(HashMap::new()),
} }
} }
@ -139,18 +160,28 @@ impl Authenticator {
!self.hashes.is_empty() !self.hashes.is_empty()
} }
/// The role an anonymous (headerless) request receives — the most
/// privileged unguarded role, or `None` when every role is guarded.
pub fn unauthenticated_role(&self) -> Option<Role> {
self.unauthenticated
}
/// Resolves the request's `authorization` header value to a role. /// Resolves the request's `authorization` header value to a role.
/// ///
/// With auth disabled everyone is [`Role::Owner`]. Every failure — /// A request with **no** `authorization` header is anonymous and
/// missing header, wrong scheme, broken base64, unknown user, /// receives [`Authenticator::unauthenticated_role`] — the most
/// wrong password — answers the same `UNAUTHENTICATED` so callers /// privileged unguarded role — or is denied when every role is
/// cannot probe which part was wrong. Never panics on input. /// guarded. A request that **does** carry a header is attempting to
/// authenticate; every way that can fail — wrong scheme, broken
/// base64, unknown user, wrong password — answers the same
/// `UNAUTHENTICATED` so callers cannot probe which part was wrong.
/// It never silently falls back to the anonymous role. Never panics
/// on input.
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> { pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
if !self.enabled() {
return Ok(Role::Owner);
}
let denied = || Status::unauthenticated("credentials required"); let denied = || Status::unauthenticated("credentials required");
let header = header.ok_or_else(denied)?; let Some(header) = header else {
return self.unauthenticated.ok_or_else(denied);
};
if let Some(role) = self if let Some(role) = self
.verified .verified
.read() .read()
@ -371,9 +402,56 @@ mod tests {
fn without_configured_hashes_everyone_is_owner() { fn without_configured_hashes_everyone_is_owner() {
let auth = Authenticator::new(&AuthSettings::default()); let auth = Authenticator::new(&AuthSettings::default());
assert!(!auth.enabled()); assert!(!auth.enabled());
assert_eq!(auth.unauthenticated_role(), Some(Role::Owner));
assert_eq!(auth.authenticate(None).expect("open"), Role::Owner); assert_eq!(auth.authenticate(None).expect("open"), Role::Owner);
} }
#[test]
fn the_unauthenticated_role_is_the_highest_unguarded_one() {
let owner = hash("o");
let qo = hash("qo");
let qa = hash("qa");
// Nothing guarded → owner. Owner guarded → queue-owner. Owner +
// queue-owner guarded → queue-appender. All guarded → denied.
let cases = [
(None, None, None, Some(Role::Owner)),
(Some(&owner), None, None, Some(Role::QueueOwner)),
(Some(&owner), Some(&qo), None, Some(Role::QueueAppender)),
(Some(&owner), Some(&qo), Some(&qa), None),
];
for (owner, queue_owner, queue_appender, expected) in cases {
let auth = Authenticator::new(&AuthSettings {
owner: owner.cloned(),
queue_owner: queue_owner.cloned(),
queue_appender: queue_appender.cloned(),
});
assert_eq!(auth.unauthenticated_role(), expected);
match expected {
Some(role) => assert_eq!(auth.authenticate(None).expect("anon"), role),
None => {
let err = auth.authenticate(None).expect_err("locked");
assert_eq!(err.code(), tonic::Code::Unauthenticated);
}
}
}
}
#[test]
fn a_header_carrying_bad_credentials_never_falls_back_to_anonymous() {
// owner guarded, so anonymous callers are queue-owner — but a
// present-yet-wrong owner login is denied, not downgraded.
let auth = Authenticator::new(&AuthSettings {
owner: Some(hash("os")),
queue_owner: None,
queue_appender: None,
});
assert_eq!(auth.unauthenticated_role(), Some(Role::QueueOwner));
let err = auth
.authenticate(Some(&basic("owner", "wrong")))
.expect_err("wrong password denied");
assert_eq!(err.code(), tonic::Code::Unauthenticated);
}
#[test] #[test]
fn valid_credentials_resolve_their_role() { fn valid_credentials_resolve_their_role() {
let auth = authenticator(); let auth = authenticator();
@ -392,8 +470,10 @@ mod tests {
#[test] #[test]
fn every_failure_is_the_same_unauthenticated() { fn every_failure_is_the_same_unauthenticated() {
let auth = authenticator(); let auth = authenticator();
// A missing header is *not* a failure here — it yields the
// anonymous role. Only present-but-bad headers are failures, and
// they must be indistinguishable.
let cases: Vec<Option<String>> = vec![ let cases: Vec<Option<String>> = vec![
None, // no header
Some("Bearer token".to_string()), // wrong scheme Some("Bearer token".to_string()), // wrong scheme
Some("Basic !!!not-base64!!!".to_string()), // broken base64 Some("Basic !!!not-base64!!!".to_string()), // broken base64
Some("Basic bm9jb2xvbg==".to_string()), // no colon Some("Basic bm9jb2xvbg==".to_string()), // no colon
@ -487,17 +567,26 @@ mod tests {
#[test] #[test]
fn the_layer_forwards_authorized_requests_only() { fn the_layer_forwards_authorized_requests_only() {
// owner + queue-owner guarded, appender open → anonymous callers
// are queue-appenders.
let auth = Arc::new(authenticator()); let auth = Arc::new(authenticator());
let append = "http://s/crabidy.v1.CrabidyService/Append"; let append = "http://s/crabidy.v1.CrabidyService/Append";
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode"; let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
// No credentials: unauthenticated, handler never runs. // No credentials: the anonymous role (queue-appender) is enough
// for Append, so the handler runs.
let (response, reached) = call_layer(auth.clone(), append, None); let (response, reached) = call_layer(auth.clone(), append, None);
assert!(!reached); assert!(reached);
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED"); assert_eq!(response.body(), "handled");
// Sufficient role: forwarded. // No credentials against an owner-only method: the anonymous role
let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os"))); // is insufficient, denied before the handler runs.
let (response, reached) = call_layer(auth.clone(), capture, None);
assert!(!reached);
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
// Sufficient role via credentials: forwarded.
let (response, reached) = call_layer(auth.clone(), capture, Some(&basic("owner", "os")));
assert!(reached); assert!(reached);
assert_eq!(response.body(), "handled"); assert_eq!(response.body(), "handled");
@ -509,6 +598,17 @@ mod tests {
assert!(!reached); assert!(!reached);
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED"); assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
// Fully locked (every role guarded): an anonymous request is
// denied with UNAUTHENTICATED before any handler runs.
let locked = Arc::new(Authenticator::new(&AuthSettings {
owner: Some(hash("os")),
queue_owner: Some(hash("qos")),
queue_appender: Some(hash("aps")),
}));
let (response, reached) = call_layer(locked, append, None);
assert!(!reached);
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
// Auth disabled: everything forwards without a header. // Auth disabled: everything forwards without a header.
let open = Arc::new(Authenticator::new(&AuthSettings::default())); let open = Arc::new(Authenticator::new(&AuthSettings::default()));
let (_, reached) = call_layer(open, capture, None); let (_, reached) = call_layer(open, capture, None);

View File

@ -1,324 +0,0 @@
//! Captured library subtrees ("bookmarks") on disk
//! (see `architecture/bookmarks.md`).
//!
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
//! mirrors the captured subtree: one order-prefixed folder per child node,
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
//! directory is mounted read-only into the library as `/bookmarks` by an
//! `fsdy` instance (with editable top-level folders) — this module is the
//! only writer.
use std::path::{Path, PathBuf};
use crabidy_core::ProviderClient;
/// The library mount point of the bookmarks directory.
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
/// The walk aborts beyond this many directories — a runaway provider tree
/// must not fill the disk.
pub const MAX_CAPTURE_DIRS: usize = crate::capture::BOOKMARK_CAPS.max_dirs;
/// The walk aborts beyond this many track files.
pub const MAX_CAPTURE_TRACKS: usize = crate::capture::BOOKMARK_CAPS.max_tracks;
/// The bookmarks directory: `bookmarks/` inside the crabidy config
/// directory. `None` when the platform has no config directory.
pub fn bookmarks_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
}
pub use crate::capture::CaptureError;
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
/// built as a hidden temp sibling and swapped into place, so a crash never
/// leaves a half-written bookmark next to intact ones.
#[derive(Debug)]
pub struct BookmarkStore {
dir: PathBuf,
}
impl BookmarkStore {
/// Opens the store at `dir`, creating the directory (and parents) if
/// missing.
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
tokio::fs::create_dir_all(&dir).await?;
Ok(Self { dir })
}
/// The store directory (what the `/bookmarks` provider instance
/// mounts).
pub fn dir(&self) -> &Path {
&self.dir
}
/// Validates a bookmark request without writing anything: the name
/// must be a legal folder name. The accept-then-stream RPC replies
/// after this and runs [`Self::capture`] detached
/// (architecture/incremental-captures.md D4).
pub fn validate(&self, name: &str) -> Result<(), CaptureError> {
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
Ok(())
}
/// Captures the subtree at `source_path` as the bookmark `name`,
/// overwriting an existing bookmark of that name. Reports through
/// `progress` (non-terminal events only; the caller sends the
/// terminal one).
///
/// Walks `client` (the orchestrator, so any provider is reachable)
/// iteratively in pre-order: every child node becomes an
/// order-prefixed folder, every track an order-prefixed link file
/// ([`fsdy::TrackFile::from_track`]; a skipped source track writes a
/// skipped toml). A `source_path` that is itself a track captures as
/// a folder with one file. Aborts with [`CaptureError::TooLarge`]
/// beyond [`MAX_CAPTURE_DIRS`] / [`MAX_CAPTURE_TRACKS`]; an
/// unreadable source is [`CaptureError::BadSource`]. Never panics on
/// provider contents.
pub async fn capture<C>(
&self,
client: &C,
source_path: &str,
name: &str,
progress: &crate::capture::Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
self.capture_with_caps(
client,
source_path,
name,
MAX_CAPTURE_DIRS,
MAX_CAPTURE_TRACKS,
progress,
)
.await
}
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
async fn capture_with_caps<C>(
&self,
client: &C,
source_path: &str,
name: &str,
max_dirs: usize,
max_tracks: usize,
progress: &crate::capture::Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
let caps = crate::capture::Caps {
max_dirs,
max_tracks,
..crate::capture::BOOKMARK_CAPS
};
crate::capture::capture_into(
&self.dir,
client,
source_path,
name,
caps,
&crate::capture::Sink::Link,
progress,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
/// A silent progress reporter; bookmark tests assert on disk state.
fn silent() -> crate::capture::Progress {
crate::capture::Progress::silent("test", false)
}
/// A real fsdy instance as the capture source: an artist with two
/// albums holding url tracks, plus one link track pointing at Tidal.
async fn source() -> (fsdy::Client, TempDir) {
let dir = TempDir::new().expect("source tempdir");
let al1 = dir.path().join("artist/Album One");
let al2 = dir.path().join("artist/Album Two");
fs::create_dir_all(&al1).expect("mkdir");
fs::create_dir_all(&al2).expect("mkdir");
let url =
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
fs::write(
al2.join("01 linked.cbd-track.toml"),
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
)
.expect("write");
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
(client, dir)
}
async fn store() -> (BookmarkStore, TempDir) {
let dir = TempDir::new().expect("store tempdir");
let store = BookmarkStore::open(dir.path().join("bookmarks"))
.await
.expect("open creates the directory");
(store, dir)
}
fn visible(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("dir")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.collect();
names.sort_by_key(|n| n.to_lowercase());
names
}
#[tokio::test]
async fn capture_mirrors_a_subtree_with_order_prefixes() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
store
.capture(&client, "/fs/artist", "faves", &silent())
.await
.expect("capture");
let root = store.dir().join("faves");
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
let album1 = visible(&root.join("0001 Album One"));
assert_eq!(
album1,
vec![
"0001 one.cbd-track.toml".to_string(),
"0002 two.cbd-track.toml".into()
]
);
// Entries are link files; the url track links back to its /fs path,
// the link track re-links to its original target (no chains).
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
.expect("read entry");
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
assert_eq!(
one.to_track("/bookmarks/irrelevant").path,
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
);
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
.expect("read entry");
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
assert_eq!(
linked.to_track("/bookmarks/irrelevant").path,
"/tidal/artists/1/2"
);
}
#[tokio::test]
async fn captured_trees_replay_through_a_bookmarks_instance() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
store
.capture(&client, "/fs/artist", "faves", &silent())
.await
.expect("capture");
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("bookmarks instance");
let (chunk_tx, chunk_rx) = flume::bounded(8);
bookmarks
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
.await
.expect("resolve");
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
// Pre-order over the mirrored structure == source listing order.
assert_eq!(
titles,
vec!["one".to_string(), "two".into(), "linked".into()]
);
}
#[tokio::test]
async fn capturing_a_single_track_writes_one_file() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
store
.capture(
&client,
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
"just one",
&silent(),
)
.await
.expect("capture track");
let entries = visible(&store.dir().join("just one"));
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
}
#[tokio::test]
async fn capture_validates_names_and_overwrites() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
for bad in ["", " ", "a/b", ".hidden"] {
assert!(
matches!(
store.capture(&client, "/fs/artist", bad, &silent()).await,
Err(CaptureError::InvalidName(_))
),
"name {bad:?} must be rejected"
);
}
store
.capture(&client, "/fs/artist", "faves", &silent())
.await
.expect("first capture");
store
.capture(&client, "/fs/artist/Album%20Two", "faves", &silent())
.await
.expect("overwrite");
// The overwrite fully replaces the older, larger capture.
assert_eq!(
visible(&store.dir().join("faves")),
vec!["0001 linked.cbd-track.toml"]
);
}
#[tokio::test]
async fn capture_rejects_an_unreadable_source() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
assert!(matches!(
store.capture(&client, "/fs/nope", "x", &silent()).await,
Err(CaptureError::BadSource(_))
));
}
#[tokio::test]
async fn capture_aborts_over_the_caps_and_cleans_up() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
let err = store
.capture_with_caps(
&client,
"/fs/artist",
"big",
2,
MAX_CAPTURE_TRACKS,
&silent(),
)
.await
.expect_err("over the dir cap");
assert!(matches!(err, CaptureError::TooLarge(_)));
// ... same for the track cap.
let err = store
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1, &silent())
.await
.expect_err("over the track cap");
assert!(matches!(err, CaptureError::TooLarge(_)));
// Nothing half-written survives, not even hidden temp folders.
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
assert_eq!(leftovers, 0);
}
}

View File

@ -1,28 +1,19 @@
//! The shared subtree-capture walk //! Capture primitives shared by the `/crabidy` store
//! (see `architecture/captures.md` D2 and //! (see `architecture/crabidy-store.md` D4).
//! `architecture/incremental-captures.md` D2).
//! //!
//! Both bookmark captures (`w`, link files) and download captures (`W`, //! [`enumerate`] walks a library subtree into a flat list of tracks (each
//! audio files next to their tomls) mirror a library subtree into a folder. //! with its target directory and listing index), enforcing the size
//! The walk runs in two phases: **enumerate** first (every directory and //! [`Caps`]; [`Downloader::download_to`] streams one stream URL into a file
//! track, enforcing the size caps — this makes the total known before the //! with the player's bounded windowing; [`Progress`] reports a capture's
//! first download), then **fetch** track by track, reporting [`Progress`] //! settling counters to clients. The `CrabidyStore` (`crabidy_store.rs`)
//! after each one. //! drives these into the content store, de-duplicating per track and
//! //! swapping the finished save into place.
//! The per-track [`Sink`] decides the write mode: bookmarks build the whole
//! capture in a hidden tmp sibling and swap it into place (all-or-nothing,
//! overwrite = refresh), download captures write **incrementally** into the
//! final folder — entries that are already satisfied are reused, tracks
//! whose source cannot be captured are recorded as *skipped* tomls, and a
//! real download failure aborts the run but keeps everything written so
//! far, so re-running the same name resumes where it stopped.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use crabidy_core::proto::crabidy::{CaptureProgress, Track}; use crabidy_core::proto::crabidy::{CaptureProgress, Track};
use crabidy_core::ProviderClient; use crabidy_core::ProviderClient;
use tracing::warn;
/// Connect timeout for download requests. /// Connect timeout for download requests.
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
@ -50,8 +41,8 @@ pub struct Caps {
pub max_dirs: usize, pub max_dirs: usize,
/// Maximum track files. /// Maximum track files.
pub max_tracks: usize, pub max_tracks: usize,
/// Maximum total bytes downloaded in one run (ignored by /// Maximum total bytes downloaded in one run (ignored by link saves).
/// [`Sink::Link`]). Reused entries of a resumed capture do not count. /// De-duplicated tracks (provider-id or hash hits) do not count.
pub max_bytes: u64, pub max_bytes: u64,
} }
@ -84,6 +75,8 @@ pub enum CaptureError {
Disabled, Disabled,
#[error("the source path cannot be captured: {0}")] #[error("the source path cannot be captured: {0}")]
BadSource(String), BadSource(String),
#[error("a save named \"{0}\" already exists")]
Conflict(String),
#[error("the source does not allow downloads")] #[error("the source does not allow downloads")]
Unsupported, Unsupported,
#[error("the subtree is too large to capture ({0})")] #[error("the subtree is too large to capture ({0})")]
@ -94,6 +87,8 @@ pub enum CaptureError {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error(transparent)] #[error(transparent)]
TrackFile(#[from] fsdy::TrackFileError), TrackFile(#[from] fsdy::TrackFileError),
#[error(transparent)]
Store(#[from] crate::crabidy_store::StoreError),
} }
/// Progress reporting for one capture run /// Progress reporting for one capture run
@ -155,21 +150,21 @@ impl Progress {
} }
} }
fn set_total(&self, total: usize) { pub fn set_total(&self, total: usize) {
self.total self.total
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed); .store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
self.publish(); self.publish();
} }
/// One track settled with playable data (reused, downloaded, linked). /// One track settled with playable data (reused, downloaded, linked).
fn track_done(&self) { pub fn track_done(&self) {
self.done.fetch_add(1, Ordering::Relaxed); self.done.fetch_add(1, Ordering::Relaxed);
self.publish(); self.publish();
} }
/// One track settled as skipped (counts toward done — the ratio must /// One track settled as skipped (counts toward done — the ratio must
/// reach total on success). /// reach total on success).
fn track_skipped(&self) { pub fn track_skipped(&self) {
self.skipped.fetch_add(1, Ordering::Relaxed); self.skipped.fetch_add(1, Ordering::Relaxed);
self.done.fetch_add(1, Ordering::Relaxed); self.done.fetch_add(1, Ordering::Relaxed);
self.publish(); self.publish();
@ -187,32 +182,12 @@ impl Progress {
} }
} }
/// What happens to each track the walk visits.
#[derive(Debug)]
pub enum Sink {
/// Write an order-prefixed link file ([`fsdy::TrackFile::from_track`])
/// — the bookmark behavior: tmp-and-swap, all-or-nothing.
Link,
/// Download the track's audio next to an order-prefixed toml that
/// points at it — the captures behavior: incremental into the final
/// folder, resumable by name.
Download(Downloader),
}
/// How one visited track settled during the fetch phase.
enum TrackOutcome {
/// Playable data is in place (downloaded now, or already there).
Captured,
/// The source cannot be captured; a skipped toml records the gap.
Skipped,
}
/// One track discovered by the enumeration phase: what to fetch, where to /// One track discovered by the enumeration phase: what to fetch, where to
/// put it, and its listing position (the order prefix). /// put it, and its listing position (the order prefix).
struct TrackEntry { pub(crate) struct TrackEntry {
track: Track, pub(crate) track: Track,
dir: PathBuf, pub(crate) dir: PathBuf,
index: usize, pub(crate) index: usize,
} }
/// Downloads one track's audio via the provider's stream URL. /// Downloads one track's audio via the provider's stream URL.
@ -244,89 +219,45 @@ impl Downloader {
Ok(Self { http, window }) Ok(Self { http, window })
} }
/// Settles one track: audio file first, then the toml pointing at /// Streams the stream URL `url` for `track_path` into the file `dest`,
/// it — a toml never exists without its audio. The whole operation is /// returning the audio extension chosen from the response
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`]. /// `Content-Type`/URL. Bounded end to end by [`DOWNLOAD_TRACK_TIMEOUT`].
/// ///
/// Error messages carry the track's library path, never the stream /// Error messages carry `track_path`, never the URL (it may embed a
/// URL (it may embed a token) — reqwest errors are stripped with /// token) — reqwest errors are stripped with
/// [`reqwest::Error::without_url`]. /// [`reqwest::Error::without_url`]. Writes no toml: the content store
async fn download_track<C>( /// hashes `dest` and decides where it lands
/// (architecture/crabidy-store.md D4).
pub async fn download_to(
&self, &self,
client: &C, track_path: &str,
track: &Track, url: &str,
dir: &Path, dest: &Path,
index: usize,
bytes_left: &mut u64, bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError> ) -> Result<String, CaptureError> {
where match tokio::time::timeout(
C: ProviderClient + Sync,
{
let fetched = tokio::time::timeout(
DOWNLOAD_TRACK_TIMEOUT, DOWNLOAD_TRACK_TIMEOUT,
self.fetch_track(client, track, dir, index, bytes_left), self.download_windowed(track_path, url, dest, bytes_left),
) )
.await; .await
match fetched { {
Ok(result) => result, Ok(result) => result,
Err(_) => Err(CaptureError::Download(format!( Err(_) => Err(CaptureError::Download(format!(
"{}: timed out after {}s", "{track_path}: timed out after {}s",
track.path,
DOWNLOAD_TRACK_TIMEOUT.as_secs() DOWNLOAD_TRACK_TIMEOUT.as_secs()
))), ))),
} }
} }
/// The unbounded body of [`Self::download_track`]: resolve the stream /// The unbounded body of [`Self::download_to`]: the windowed range
/// URL, stream the response to disk against the byte budget, then /// download, streaming the response to `dest` against the byte budget.
/// write the toml. async fn download_windowed(
///
/// A track whose source cannot be captured — it is itself skipped, its
/// stream fails to resolve, or it resolves to something other than an
/// http(s) URL (e.g. a local file playable) — is recorded as a
/// **skipped toml** instead of aborting the capture
/// (architecture/incremental-captures.md D2): queue and bookmark
/// captures mix providers, and one local track must not kill the rest.
/// Actual download failures stay fatal for the run.
async fn fetch_track<C>(
&self, &self,
client: &C, track_path: &str,
track: &Track, url: &str,
dir: &Path, dest: &Path,
index: usize,
bytes_left: &mut u64, bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError> ) -> Result<String, CaptureError> {
where
C: ProviderClient + Sync,
{
let track_path = track.path.as_str();
if track.is_skipped {
warn!(path = track_path, "recording an already-skipped track");
return write_skipped(track, dir, index).await;
}
let urls = match client.get_urls_for_track(track_path).await {
Ok(urls) => urls,
Err(err) => {
warn!(
path = track_path,
"recording uncapturable track as skipped: {err}"
);
return write_skipped(track, dir, index).await;
}
};
let Some(url) = urls.first() else {
warn!(
path = track_path,
"recording track without a stream url as skipped"
);
return write_skipped(track, dir, index).await;
};
if !(url.starts_with("http://") || url.starts_with("https://")) {
// A local file playable (an fs track, or a re-captured
// capture): the audio is already on disk, so copy it into the
// capture instead of skipping it.
return self.copy_local(track, url, dir, index, bytes_left).await;
}
let download_err = |err: reqwest::Error| { let download_err = |err: reqwest::Error| {
CaptureError::Download(format!("{track_path}: {}", err.without_url())) CaptureError::Download(format!("{track_path}: {}", err.without_url()))
}; };
@ -360,8 +291,7 @@ impl Downloader {
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.map(str::to_string); .map(str::to_string);
let ext = extension_for(content_type.as_deref(), url); let ext = extension_for(content_type.as_deref(), url);
let audio_name = audio_file_name(index, &track.title, &ext); let mut audio = tokio::fs::File::create(dest).await?;
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
loop { loop {
// The window's extent and the resource total, from // The window's extent and the resource total, from
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length // `Content-Range: bytes <a>-<b>/<total>` (Content-Length
@ -423,56 +353,7 @@ impl Downloader {
} }
} }
tokio::io::AsyncWriteExt::flush(&mut audio).await?; tokio::io::AsyncWriteExt::flush(&mut audio).await?;
drop(audio); Ok(ext)
let text =
fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name)).to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
Ok(TrackOutcome::Captured)
}
/// Copies an already-local audio file (an fs playable, or a track
/// from an existing capture) into the capture folder next to its
/// toml. A source that is missing or not a regular file is recorded
/// as skipped rather than aborting the run; the copy counts against
/// the same byte budget as a download so a capture cannot run away.
async fn copy_local(
&self,
track: &Track,
source: &str,
dir: &Path,
index: usize,
bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError> {
let track_path = track.path.as_str();
let source_path = Path::new(source);
match tokio::fs::metadata(source_path).await {
Ok(meta) if meta.is_file() => {
let len = meta.len();
if len > *bytes_left {
return Err(CaptureError::TooLarge("download budget exhausted"));
}
let ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let audio_name = audio_file_name(index, &track.title, ext);
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
*bytes_left -= len;
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
.to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
.await?;
Ok(TrackOutcome::Captured)
}
_ => {
warn!(
path = track_path,
"local playable is not a readable file; recording as skipped"
);
write_skipped(track, dir, index).await
}
}
} }
} }
@ -488,127 +369,12 @@ fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total)) Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
} }
/// Writes the skipped toml for `track` at listing position `index`, /// Mirrors the directory structure under `root` and collects every track
/// overwriting whatever was there. /// with its target directory and listing index. Iterative pre-order over
async fn write_skipped( /// [`ProviderClient::get_lib_node`] (a deep tree must not overflow the
track: &Track, /// stack). A `source_path` that is a track enumerates as a single entry.
dir: &Path, /// Enforces `max_dirs`/`max_tracks`.
index: usize, pub(crate) async fn enumerate<C>(
) -> Result<TrackOutcome, CaptureError> {
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
Ok(TrackOutcome::Skipped)
}
/// Captures the subtree at `source_path` as `dir/<name>/`.
///
/// Validates `name` ([`fsdy::validate_folder_name`], nothing reserved),
/// then enumerates the subtree (caps enforced, total reported) and fetches
/// track by track. [`Sink::Link`] builds the whole capture in a hidden
/// `.tmp-<name>` sibling and swaps it into place, removing the temp folder
/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes
/// incrementally into `dir/<name>` itself: satisfied entries are reused,
/// uncapturable tracks become skipped tomls, and failures keep everything
/// already written (re-run the same name to resume).
pub async fn capture_into<C>(
dir: &Path,
client: &C,
source_path: &str,
name: &str,
caps: Caps,
sink: &Sink,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
match sink {
Sink::Link => {
let tmp = dir.join(format!(".tmp-{name}"));
let written = write_links(client, source_path, &tmp, caps, progress).await;
if let Err(err) = written {
// Every failure path removes the temp folder: nothing
// half-written survives, not even hidden.
let _ = tokio::fs::remove_dir_all(&tmp).await;
return Err(err);
}
let target = dir.join(name);
if tokio::fs::try_exists(&target).await? {
tokio::fs::remove_dir_all(&target).await?;
}
tokio::fs::rename(&tmp, &target).await?;
Ok(())
}
Sink::Download(downloader) => {
let target = dir.join(name);
tokio::fs::create_dir_all(&target).await?;
let entries = enumerate(client, source_path, &target, caps).await?;
progress.set_total(entries.len());
let mut bytes_left = caps.max_bytes;
for entry in &entries {
if existing_is_satisfied(&entry.dir, entry.index, &entry.track.title).await {
progress.track_done();
continue;
}
let outcome = downloader
.download_track(
client,
&entry.track,
&entry.dir,
entry.index,
&mut bytes_left,
)
.await?;
match outcome {
TrackOutcome::Captured => progress.track_done(),
TrackOutcome::Skipped => progress.track_skipped(),
}
}
Ok(())
}
}
}
/// The bookmark walk body: fresh tmp folder, enumerate, one link file per
/// track ([`fsdy::TrackFile::from_track`] — skipped source tracks write
/// skipped tomls). The caller owns cleanup on error.
async fn write_links<C>(
client: &C,
source_path: &str,
tmp: &Path,
caps: Caps,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
// A leftover temp folder from a crashed or racing capture is stale.
if tokio::fs::try_exists(tmp).await? {
tokio::fs::remove_dir_all(tmp).await?;
}
tokio::fs::create_dir_all(tmp).await?;
let entries = enumerate(client, source_path, tmp, caps).await?;
progress.set_total(entries.len());
for entry in &entries {
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
let file = entry
.dir
.join(fsdy::track_file_name(entry.index, &entry.track.title));
tokio::fs::write(file, text).await?;
progress.track_done();
}
Ok(())
}
/// Phase 1: mirrors the directory structure under `root` and collects
/// every track with its target directory and listing index. Iterative
/// pre-order over [`ProviderClient::get_lib_node`] (a deep tree must not
/// overflow the stack); existing directories are reused
/// (`create_dir_all`), which is what makes download captures resumable. A
/// `source_path` that is a track enumerates as a single entry. Enforces
/// `max_dirs`/`max_tracks`.
async fn enumerate<C>(
client: &C, client: &C,
source_path: &str, source_path: &str,
root: &Path, root: &Path,
@ -661,35 +427,6 @@ where
Ok(entries) Ok(entries)
} }
/// Whether the entry for track `title` at listing position `index` in
/// `dir` already has playable data: a parseable toml whose playable is not
/// skipped, and — for a `file` playable — whose audio file exists.
/// Anything else (missing, broken, skipped, audio gone) is re-captured.
async fn existing_is_satisfied(dir: &Path, index: usize, title: &str) -> bool {
let toml_path = dir.join(fsdy::track_file_name(index, title));
let Ok(text) = tokio::fs::read_to_string(&toml_path).await else {
return false;
};
let Ok(file) = fsdy::TrackFile::parse(&text) else {
return false;
};
match file.playable() {
Ok(fsdy::Playable::File(target)) => {
let absolute = if target.is_absolute() {
target
} else {
dir.join(target)
};
tokio::fs::try_exists(absolute).await.unwrap_or(false)
}
Ok(fsdy::Playable::Skipped) => false,
// A url/link playable was not written by this store, but whoever
// put it there gave the entry playable data — keep it.
Ok(_) => true,
Err(_) => false,
}
}
/// Picks the audio file extension: the response `Content-Type` first /// Picks the audio file extension: the response `Content-Type` first
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` → /// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's /// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
@ -729,14 +466,6 @@ fn url_extension(url: &str) -> Option<&str> {
plausible.then_some(ext) plausible.then_some(ext)
} }
/// The audio file name for track `index` titled `title` with `ext`, next
/// to its toml: `NNNN <title>.<ext>` through the same sanitizer as the
/// toml name, so the pair sorts together — and what the toml's relative
/// `file` playable points at.
fn audio_file_name(index: usize, title: &str, ext: &str) -> String {
format!("{}.{ext}", fsdy::dir_name(index, title))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -783,17 +512,6 @@ mod tests {
assert_eq!(parse_content_range("garbage"), None); assert_eq!(parse_content_range("garbage"), None);
} }
#[test]
fn audio_files_pair_with_their_toml_names() {
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
// audio file sorts right next to its toml.
assert_eq!(
audio_file_name(0, "We Will Rock You", "flac"),
"0001 We Will Rock You.flac"
);
assert_eq!(audio_file_name(11, "a/b", "mp3"), "0012 a_b.mp3");
}
#[test] #[test]
fn progress_counts_skipped_toward_done_and_finishes_once() { fn progress_counts_skipped_toward_done_and_finishes_once() {
let (tx, rx) = flume::bounded(16); let (tx, rx) = flume::bounded(16);

View File

@ -1,783 +0,0 @@
//! Downloaded library subtrees ("captures") on disk
//! (see `architecture/captures.md` and
//! `architecture/incremental-captures.md`).
//!
//! Every capture is a folder under `<config>/crabidy/captures/` that
//! mirrors the captured subtree like a bookmark, except each track's audio
//! is **downloaded** next to its order-prefixed `*.cbd-track.toml`, and the
//! toml's playable is a relative `file` pointing at it — replaying a
//! capture needs no provider round trip. Tracks whose source cannot be
//! captured are recorded as *skipped* tomls. The same directory is mounted
//! read-only into the library as `/captures` by an `fsdy` instance (with
//! editable top-level folders); this module is the only writer.
use std::path::{Path, PathBuf};
use crabidy_core::ProviderClient;
use crate::capture::{Caps, CaptureError, Progress};
/// The library mount point of the captures directory.
pub const CAPTURES_PROVIDER_ROOT: &str = "/captures";
/// The captures directory: `captures/` inside the crabidy config
/// directory. `None` when the platform has no config directory.
pub fn captures_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("crabidy").join("captures"))
}
/// Writes download captures. All audio is fetched through one shared HTTP
/// client. Captures are **incremental**: the walk writes into the final
/// folder, reuses entries that already have their audio, and keeps
/// everything written so far when a download fails — capturing the same
/// name again resumes (and completes previously skipped entries where the
/// source became capturable).
#[derive(Debug)]
pub struct CaptureStore {
dir: PathBuf,
sink: crate::capture::Sink,
}
impl CaptureStore {
/// Opens the store at `dir`, creating the directory (and parents) if
/// missing, and builds the shared HTTP client.
pub async fn open(dir: PathBuf) -> Result<Self, CaptureError> {
tokio::fs::create_dir_all(&dir).await?;
let downloader = crate::capture::Downloader::new()
.map_err(|err| CaptureError::Download(format!("cannot build http client: {err}")))?;
Ok(Self {
dir,
sink: crate::capture::Sink::Download(downloader),
})
}
/// The store directory (what the `/captures` provider instance
/// mounts).
pub fn dir(&self) -> &Path {
&self.dir
}
/// Validates a capture request without writing anything: the name must
/// be a legal folder name and the capture **root** must opt in
/// (`architecture/captures.md` D4 — a directory source must report
/// `is_downloadable`, a track source's parent node must). The
/// accept-then-stream RPC replies after this and runs
/// [`Self::capture`] detached (architecture/incremental-captures.md
/// D4).
pub async fn validate<C>(
&self,
client: &C,
source_path: &str,
name: &str,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
source_allows_download(client, source_path).await
}
/// Captures the subtree at `source_path` as the capture `name`,
/// downloading every track's audio — **incrementally**: an existing
/// capture of that name is resumed, not overwritten (satisfied entries
/// are reused, skipped and broken ones re-attempted). Reports through
/// `progress` (non-terminal events only; the caller sends the terminal
/// one).
///
/// The source must pass [`Self::validate`]. The walk, caps
/// ([`crate::capture::DOWNLOAD_CAPS`]), reuse rule, and skipped tomls
/// are [`crate::capture::capture_into`]'s.
pub async fn capture<C>(
&self,
client: &C,
source_path: &str,
name: &str,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
self.capture_with_caps(
client,
source_path,
name,
crate::capture::DOWNLOAD_CAPS,
progress,
)
.await
}
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
async fn capture_with_caps<C>(
&self,
client: &C,
source_path: &str,
name: &str,
caps: Caps,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
source_allows_download(client, source_path).await?;
crate::capture::capture_into(
&self.dir,
client,
source_path,
name,
caps,
&self.sink,
progress,
)
.await
}
}
/// Checks that the capture root allows downloads: the node at
/// `source_path` (or, for a track path, its parent node) must set
/// `is_downloadable`. An unreadable root is [`CaptureError::BadSource`].
async fn source_allows_download<C>(client: &C, source_path: &str) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
let node_path = if client.is_track_path(source_path) {
crabidy_core::parent_path(source_path).unwrap_or(source_path)
} else {
source_path
};
let node = client
.get_lib_node(node_path)
.await
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
if node.is_downloadable {
Ok(())
} else {
Err(CaptureError::Unsupported)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::{Caps, DOWNLOAD_CAPS};
use async_trait::async_trait;
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
use crabidy_core::ProviderError;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Serves every request with one fixed response and returns a URL for
/// it. Minimal HTTP/1.1 on a loopback socket — enough for reqwest.
async fn serve(status: &'static str, content_type: &'static str, body: Vec<u8>) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
// Read until the header terminator; the request itself
// is irrelevant.
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&body).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}/stream")
}
/// Like [`serve`], but the server enforces bounded ranges the way
/// googlevideo does: ranged requests up to `max_window` bytes get
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
/// oversized) gets `403`.
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let request = String::from_utf8_lossy(&buf).to_lowercase();
let range = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.and_then(|spec| {
let (start, end) = spec.trim().split_once('-')?;
let start: u64 = start.parse().ok()?;
let end: u64 = end.parse().ok()?;
Some((start, end))
});
let total = body.len() as u64;
let response = match range {
Some((start, end)) if start < total && end - start < max_window => {
let end = end.min(total - 1);
let slice = &body[start as usize..=end as usize];
let mut head = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Type: {content_type}\r\n\
Content-Range: bytes {start}-{end}/{total}\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n",
slice.len()
)
.into_bytes();
head.extend_from_slice(slice);
head
}
Some((start, _)) if start >= total => format!(
"HTTP/1.1 416 Range Not Satisfiable\r\n\
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
Connection: close\r\n\r\n"
)
.into_bytes(),
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_vec(),
};
let _ = sock.write_all(&response).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}/stream")
}
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
/// stream URLs are the test server's, and a node-level download
/// blessing toggle.
#[derive(Debug)]
struct MockProvider {
urls: HashMap<String, String>,
downloadable: bool,
}
impl MockProvider {
fn new(urls: &[(&str, &str)], downloadable: bool) -> Self {
Self {
urls: urls
.iter()
.map(|(p, u)| (p.to_string(), u.to_string()))
.collect(),
downloadable,
}
}
fn track(&self, path: &str) -> Track {
let title = match path {
"/mock/a/1" => "one",
"/mock/a/2" => "two",
other => other,
};
Track {
path: path.to_string(),
artist: "mock".to_string(),
title: title.to_string(),
duration: Some(10),
album: None,
is_skipped: false,
}
}
}
#[async_trait]
impl ProviderClient for MockProvider {
async fn init(_s: &str) -> Result<Self, ProviderError> {
Err(ProviderError::NotSupported)
}
fn settings(&self) -> String {
String::new()
}
fn is_track_path(&self, path: &str) -> bool {
path.starts_with("/mock/a/") && path.len() > "/mock/a/".len()
}
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
self.urls
.get(track_path)
.map(|u| vec![u.clone()])
.ok_or(ProviderError::FetchError)
}
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
if !self.is_track_path(track_path) {
return Err(ProviderError::MalformedPath);
}
Ok(self.track(track_path))
}
fn get_lib_root(&self) -> LibraryNode {
LibraryNode::new()
}
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let mut node = LibraryNode::new();
node.path = path.to_string();
node.is_downloadable = self.downloadable;
match path {
"/mock" => {
node.title = "mock".to_string();
node.children = vec![LibraryNodeChild {
is_downloadable: self.downloadable,
..LibraryNodeChild::new("/mock/a".to_string(), "a".to_string(), true)
}];
}
"/mock/a" => {
node.title = "a".to_string();
node.is_queable = true;
node.tracks = vec![self.track("/mock/a/1"), self.track("/mock/a/2")];
}
_ => return Err(ProviderError::MalformedPath),
}
Ok(node)
}
async fn create_lib_node(
&self,
_parent_path: &str,
_title: &str,
) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn rename_lib_node(
&self,
_path: &str,
_new_title: &str,
) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
}
async fn store() -> (CaptureStore, TempDir) {
let dir = TempDir::new().expect("store tempdir");
let store = CaptureStore::open(dir.path().join("captures"))
.await
.expect("open creates the directory");
(store, dir)
}
fn visible(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("dir")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.collect();
names.sort_by_key(|n| n.to_lowercase());
names
}
/// A silent progress reporter for tests that do not assert on events.
fn silent() -> Progress {
Progress::silent("test", true)
}
/// Parses the capture entry's toml and returns its playable.
fn playable_of(dir: &Path, name: &str) -> fsdy::Playable {
let text = fs::read_to_string(dir.join(name)).expect("toml");
fsdy::TrackFile::parse(&text)
.expect("parses")
.playable()
.expect("playable")
}
#[tokio::test]
async fn download_capture_writes_audio_next_to_pointing_tomls() {
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
let (store, _dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture");
let root = store.dir().join("faves");
assert_eq!(
visible(&root),
vec![
"0001 one.cbd-track.toml".to_string(),
"0001 one.flac".into(),
"0002 two.cbd-track.toml".into(),
"0002 two.flac".into(),
]
);
assert_eq!(
fs::read(root.join("0001 one.flac")).expect("audio"),
b"flacbytes"
);
// The toml points at its sibling with a *relative* file playable.
assert_eq!(
playable_of(&root, "0002 two.cbd-track.toml"),
fsdy::Playable::File("0002 two.flac".into())
);
// Replay: the capture resolves through a /captures instance and the
// audio resolves to the absolute sibling path — no provider round
// trip left.
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("captures instance");
let (chunk_tx, chunk_rx) = flume::bounded(8);
captures
.resolve_tracks_into("/captures/faves", chunk_tx)
.await
.expect("resolve");
let tracks: Vec<Track> = chunk_rx.into_iter().flatten().collect();
assert_eq!(tracks.len(), 2);
let urls = captures
.get_urls_for_track(&tracks[0].path)
.await
.expect("urls");
assert_eq!(urls, vec![root.join("0001 one.flac").display().to_string()]);
}
#[tokio::test]
async fn capture_requires_the_root_download_blessing() {
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], false);
let (store, _dir) = store().await;
// validate() is what the accept-then-stream RPC checks up front...
assert!(matches!(
store.validate(&mock, "/mock/a", "faves").await,
Err(CaptureError::Unsupported)
));
// ...and the walk itself re-checks, for both node and track roots.
assert!(matches!(
store.capture(&mock, "/mock/a", "faves", &silent()).await,
Err(CaptureError::Unsupported)
));
assert!(matches!(
store.capture(&mock, "/mock/a/1", "faves", &silent()).await,
Err(CaptureError::Unsupported)
));
assert_eq!(fs::read_dir(store.dir()).expect("store dir").count(), 0);
}
#[tokio::test]
async fn capturing_a_single_blessed_track_writes_one_pair() {
let url = serve("200 OK", "audio/mpeg", b"mp3bytes".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let (store, _dir) = store().await;
store
.capture(&mock, "/mock/a/1", "just one", &silent())
.await
.expect("capture track");
assert_eq!(
visible(&store.dir().join("just one")),
vec!["0001 one.cbd-track.toml".to_string(), "0001 one.mp3".into()]
);
}
#[tokio::test]
async fn download_failure_keeps_progress_and_resuming_completes() {
let ok = serve("200 OK", "audio/flac", b"first".to_vec()).await;
let gone = serve("404 Not Found", "text/plain", Vec::new()).await;
let mock = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &gone)], true);
let (store, _dir) = store().await;
let err = store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect_err("a failed download aborts the run");
assert!(matches!(err, CaptureError::Download(_)), "got {err:?}");
// Incremental: what downloaded before the failure survives
// (architecture/incremental-captures.md D2).
let root = store.dir().join("faves");
assert!(root.join("0001 one.cbd-track.toml").exists());
assert!(root.join("0001 one.flac").exists());
assert!(!root.join("0002 two.cbd-track.toml").exists());
// Resume with a healthy source: the satisfied entry is reused (the
// sentinel content is not re-downloaded), the missing one arrives.
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
let fixed = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &ok)], true);
store
.capture(&fixed, "/mock/a", "faves", &silent())
.await
.expect("resume completes");
assert_eq!(
fs::read(root.join("0001 one.flac")).expect("audio"),
b"sentinel",
"satisfied entries must not be re-downloaded"
);
assert!(root.join("0002 two.cbd-track.toml").exists());
assert_eq!(
fs::read(root.join("0002 two.flac")).expect("audio"),
b"first"
);
}
#[tokio::test]
async fn download_capture_enforces_its_caps() {
let url = serve("200 OK", "audio/flac", b"0123456789".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
let (store, _dir) = store().await;
// Byte budget: two 10-byte bodies against a 15-byte budget. The
// first track fits, the second trips the budget — and survives as
// partial progress (no toml, so a resume re-downloads it).
let caps = Caps {
max_bytes: 15,
..DOWNLOAD_CAPS
};
let err = store
.capture_with_caps(&mock, "/mock/a", "big", caps, &silent())
.await
.expect_err("over the byte budget");
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
let root = store.dir().join("big");
assert!(root.join("0001 one.cbd-track.toml").exists());
assert!(!root.join("0002 two.cbd-track.toml").exists());
// Track cap: enumeration fails before anything is fetched.
let caps = Caps {
max_tracks: 1,
..DOWNLOAD_CAPS
};
let err = store
.capture_with_caps(&mock, "/mock/a", "big2", caps, &silent())
.await
.expect_err("over the track cap");
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
assert_eq!(visible(&store.dir().join("big2")), Vec::<String>::new());
}
#[tokio::test]
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
// A queue-like source: a downloadable fsdy instance whose folder
// mixes an http track with a local-file track — exactly a
// persisted queue linking tidal and fs entries. Both must end up
// with local audio: the web one downloaded, the fs one copied.
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
let src = TempDir::new().expect("source tempdir");
let mix = src.path().join("mix");
fs::create_dir_all(&mix).expect("mkdir");
fs::write(
mix.join("01 web.cbd-track.toml"),
format!("title = \"web\"\n[playable]\nurl = {url:?}\n"),
)
.expect("write");
fs::write(mix.join("audio.flac"), b"local").expect("write");
fs::write(
mix.join("02 local.cbd-track.toml"),
"title = \"local\"\n[playable]\nfile = \"audio.flac\"\n",
)
.expect("write");
let source = fsdy::Client::new("/queues", src.path().to_path_buf())
.expect("source instance")
.with_downloadable_nodes();
let (store, _dir) = store().await;
store
.capture(&source, "/queues/mix", "mixed", &silent())
.await
.expect("capture");
// Both tracks captured with audio next to their toml: the web
// track downloaded, the local track copied in.
let root = store.dir().join("mixed");
assert_eq!(
visible(&root),
vec![
"0001 web.cbd-track.toml".to_string(),
"0001 web.flac".into(),
"0002 local.cbd-track.toml".into(),
"0002 local.flac".into(),
]
);
match playable_of(&root, "0002 local.cbd-track.toml") {
fsdy::Playable::File(rel) => {
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
}
other => panic!("expected the local track copied, got {other:?}"),
}
// Neither lists as skipped.
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("captures instance");
let node = captures
.get_lib_node("/captures/mixed")
.await
.expect("node");
assert_eq!(node.tracks.len(), 2);
assert!(node.tracks.iter().all(|t| !t.is_skipped));
}
#[tokio::test]
async fn skipped_entries_are_reattempted_on_resume() {
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
// Track two has no stream URL: recorded as skipped.
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let (store, _dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture with a skipped entry");
let root = store.dir().join("faves");
assert_eq!(
playable_of(&root, "0002 two.cbd-track.toml"),
fsdy::Playable::Skipped
);
// The source became capturable: re-capturing the same name
// completes the skipped entry and reuses the satisfied one.
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
let fixed = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
store
.capture(&fixed, "/mock/a", "faves", &silent())
.await
.expect("resume");
assert_eq!(
playable_of(&root, "0002 two.cbd-track.toml"),
fsdy::Playable::File("0002 two.flac".into())
);
assert_eq!(
fs::read(root.join("0001 one.flac")).expect("audio"),
b"sentinel"
);
}
#[tokio::test]
async fn capture_validates_names_and_merges_into_existing() {
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
let (store, _dir) = store().await;
assert!(matches!(
store.validate(&mock, "/mock/a", "a/b").await,
Err(CaptureError::InvalidName(_))
));
assert!(matches!(
store.capture(&mock, "/mock/a", "a/b", &silent()).await,
Err(CaptureError::InvalidName(_))
));
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("first capture");
// Capturing a smaller source into the same name merges: the
// single-track capture reuses its entry, the rest stays.
store
.capture(&mock, "/mock/a/1", "faves", &silent())
.await
.expect("merge");
assert_eq!(
visible(&store.dir().join("faves")),
vec![
"0001 one.cbd-track.toml".to_string(),
"0001 one.flac".into(),
"0002 two.cbd-track.toml".into(),
"0002 two.flac".into(),
]
);
}
#[tokio::test]
async fn downloads_chain_bounded_windows_on_strict_cdns() {
// A googlevideo-style server: only bounded ranges under 16 bytes
// are served; plain or oversized requests are 403. A downloader
// with a 10-byte window must fetch the 100-byte body completely.
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
let url = serve_ranged("audio/flac", body.clone(), 16).await;
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let dir = TempDir::new().expect("store tempdir");
let store_dir = dir.path().join("captures");
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
let store = CaptureStore {
dir: store_dir,
sink: crate::capture::Sink::Download(
crate::capture::Downloader::with_window(10).expect("downloader"),
),
};
store
.capture(&mock, "/mock/a/1", "windowed", &silent())
.await
.expect("windowed capture");
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
assert_eq!(audio, body, "all windows stitched in order");
}
#[tokio::test]
async fn capture_reports_progress_totals_and_skips() {
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
// Track two is uncapturable: one done, one skipped, total two.
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let (store, _dir) = store().await;
let (tx, rx) = flume::bounded(64);
let progress = Progress::new("faves", true, tx);
store
.capture(&mock, "/mock/a", "faves", &progress)
.await
.expect("capture");
progress.finish(None).await;
let events: Vec<_> = rx.drain().collect();
assert!(events.iter().any(|e| e.tracks_total == 2 && !e.finished));
let last = events.last().expect("terminal event");
assert!(last.finished);
assert!(last.error.is_empty());
assert_eq!(last.tracks_done, 2, "skipped counts toward done");
assert_eq!(last.tracks_skipped, 1);
assert!(last.download);
assert_eq!(last.name, "faves");
}
#[tokio::test]
async fn local_file_playables_are_copied_not_skipped() {
// An fs-style playable: the provider resolves the track to a
// local file path (not an http URL). The capture must copy that
// audio in, not record the track as skipped.
let src = TempDir::new().expect("source dir");
let audio = src.path().join("song.flac");
fs::write(&audio, b"flacdata").expect("write source audio");
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
let (store, dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture");
let capture_dir = dir.path().join("captures").join("faves");
// Track one ("one"): a File playable whose audio was copied in
// next to the toml, with the source extension and contents.
let one_toml = fsdy::track_file_name(0, "one");
match playable_of(&capture_dir, &one_toml) {
fsdy::Playable::File(rel) => {
let copied = capture_dir.join(&rel);
assert!(copied.exists(), "audio copied next to the toml");
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
}
other => panic!("expected a copied File playable, got {other:?}"),
}
// Track two ("two") has no source and is still recorded skipped.
let two_toml = fsdy::track_file_name(1, "two");
assert!(matches!(
playable_of(&capture_dir, &two_toml),
fsdy::Playable::Skipped
));
}
}

568
crabidy-server/src/cli.rs Normal file
View File

@ -0,0 +1,568 @@
//! Execution of the server-owned CLI subcommands (`guard`, `scan`) and the
//! shared helpers the bundled `cbd` binary reuses (architecture/cli.md D4/D5).
//!
//! The clap *definitions* live in `cbd-cli`; their *execution* lives here
//! because it needs the server's config writer ([`ServerSettings::store`]) and
//! the content store ([`CrabidyStore`]), which `cbd-cli` must not depend on.
use std::error::Error;
use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
use cbd_cli::{GuardArgs, Role, ScanArgs};
#[cfg(feature = "fs")]
use tracing::warn;
#[cfg(feature = "fs")]
use crate::crabidy_store::CrabidyStore;
use crate::settings::ServerSettings;
/// Default server address for the remote `library`/`queue`/`global` commands.
pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051";
/// File-name extensions treated as playable audio by `scan` (lowercased).
/// `opus` is here only when this build can decode it, so `scan` never indexes
/// a file the player would reject (architecture/build-features.md D6).
#[cfg(feature = "fs")]
const AUDIO_EXTENSIONS: &[&str] = &[
"flac",
"mp3",
"m4a",
"aac",
"ogg",
#[cfg(feature = "opus")]
"opus",
"wav",
"webm",
"wma",
"aiff",
"aif",
];
/// The crabidy config directory (`dirs::config_dir()/crabidy`).
fn config_dir() -> Result<PathBuf, Box<dyn Error>> {
dirs::config_dir()
.map(|dir| dir.join("crabidy"))
.ok_or_else(|| "no config directory available".into())
}
/// Builds a resolved [`cbd_cli::Connection`] from the shared remote flags,
/// falling back to the client defaults (localhost, no credentials).
pub fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
cbd_cli::Connection {
address: remote
.address
.clone()
.unwrap_or_else(|| DEFAULT_ADDRESS.to_string()),
user: remote.user.clone().unwrap_or_default(),
password: remote.password.clone().unwrap_or_default(),
}
}
/// Resolves the password from the CLI argument, or reads it from stdin when
/// omitted. Never echoed or logged. Rejects an empty password.
fn resolve_password(arg: Option<String>) -> Result<String, Box<dyn Error>> {
let password = match arg {
Some(password) => password,
None => {
let mut stdin = std::io::stdin();
if stdin.is_terminal() {
eprintln!("Enter password (input is not hidden):");
}
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
buf.trim_end_matches(['\r', '\n']).to_string()
}
};
if password.is_empty() {
return Err("empty password".into());
}
Ok(password)
}
/// Writes `hash` into the role's `[auth]` field of `crabidy-server.toml` in
/// `config_dir`, preserving the other roles (the testable core of `guard`'s
/// config write).
pub fn write_role_hash(config_dir: &Path, role: Role, hash: String) -> Result<(), String> {
let mut settings = ServerSettings::load(config_dir)?;
match role {
Role::Owner => settings.auth.owner = Some(hash),
Role::QueueOwner => settings.auth.queue_owner = Some(hash),
Role::Appender => settings.auth.queue_appender = Some(hash),
}
// Refuse to persist a config the server would reject on load: roles
// must be guarded from the top down (architecture/roles-auth.md). This
// catches `guard queue-owner` before `guard owner`.
settings.auth.validate()?;
settings.store(config_dir)
}
/// `guard <role> [password] [--no-config]`: hash a role password (argon2id),
/// print the PHC string to stdout, and — unless `--no-config` — write it into
/// `crabidy-server.toml`'s `[auth]` (architecture/cli.md D4). Only the hash is
/// printed to stdout, so the command stays pipeable; the confirmation goes to
/// stderr and the password is never printed or logged.
pub async fn guard(args: GuardArgs) -> Result<(), Box<dyn Error>> {
let password = resolve_password(args.password)?;
let hash = crate::auth::hash_password(&password)?;
println!("{hash}");
if !args.no_config {
let dir = config_dir()?;
write_role_hash(&dir, args.role, hash)?;
eprintln!(
"wrote the {} hash to {}",
args.role.user_name(),
dir.join(crate::settings::SETTINGS_FILE).display()
);
}
Ok(())
}
/// `audio-devices [device]`: with no argument, list the available audio output
/// devices so the user can choose one for `[audio] device` in
/// `crabidy-server.toml`, marking the device the current config selects (same
/// case-insensitive substring match the server applies at startup). With a
/// `device` argument, write it into `[audio] device` and then list — so one
/// command both configures and confirms. Fixes the common Raspberry Pi case
/// where the default device is HDMI and audio plays but is silent on the
/// jack/DAC.
pub fn audio_devices(select: Option<String>) -> Result<(), Box<dyn Error>> {
// Setting a device needs a writable config dir; listing tolerates its
// absence.
if let Some(device) = select {
let dir = config_dir()?;
let mut settings = ServerSettings::load(&dir)?;
settings.audio.device = Some(device.clone());
settings.store(&dir)?;
println!(
"Set [audio] device = \"{device}\" in {}",
dir.join(crate::settings::SETTINGS_FILE).display()
);
// The server matches the same way; warn on a fragment that currently
// resolves to nothing so a typo is caught here, not as silent output.
let needle = device.to_lowercase();
let names = audio_player::output_device_names();
if !names.is_empty()
&& !names
.iter()
.any(|name| name.to_lowercase().contains(&needle))
{
eprintln!(
"warning: no current output device name contains \"{device}\"; the server \
will fall back to the system default until one matches"
);
}
println!("Restart the server for it to take effect.\n");
}
let dir = config_dir().ok();
let configured = dir
.as_deref()
.and_then(|dir| ServerSettings::load(dir).ok())
.and_then(|settings| settings.audio.device);
let names = audio_player::output_device_names();
if names.is_empty() {
println!("No audio output devices found.");
return Ok(());
}
let needle = configured.as_ref().map(|device| device.to_lowercase());
println!("Audio output devices (* = selected by the current config):");
for name in &names {
let selected = needle
.as_ref()
.is_some_and(|needle| name.to_lowercase().contains(needle));
println!(" {}{name}", if selected { "* " } else { " " });
}
match configured {
Some(device) => println!("\n[audio] device = \"{device}\""),
None => {
let path = dir
.map(|dir| {
dir.join(crate::settings::SETTINGS_FILE)
.display()
.to_string()
})
.unwrap_or_else(|| crate::settings::SETTINGS_FILE.to_string());
println!(
"\nNo [audio] device set (using the system default). To pin one, add to {path}:\n\n[audio]\ndevice = \"Headphones\" # a name or fragment from the list above"
);
}
}
Ok(())
}
/// `scan` without the `fs` feature: the command exists (the clap surface is
/// feature-independent, so completions and the man page never vary) but this
/// build has no sidecar writer or content store
/// (architecture/build-features.md D5/D9).
#[cfg(not(feature = "fs"))]
pub async fn scan(_args: ScanArgs) -> Result<(), Box<dyn Error>> {
Err("this binary was built without the `fs` feature, so it cannot index a music folder".into())
}
/// The build features this binary was compiled with, in a stable order:
/// the providers it can mount ([`crate::settings::BUILT_IN_PROVIDERS`])
/// followed by the non-provider features (`opus`, `spectrum`, `web-ui`).
///
/// Used by the `features` subcommand and by the startup log line, so "why is
/// `/tidal` missing?" is answerable from the binary itself
/// (architecture/build-features.md D4).
pub fn build_features() -> Vec<&'static str> {
let mut features: Vec<&'static str> = crate::settings::BUILT_IN_PROVIDERS.to_vec();
if cfg!(feature = "opus") {
features.push("opus");
}
if cfg!(feature = "spectrum") {
features.push("spectrum");
}
if cfg!(feature = "web-ui") {
features.push("web-ui");
}
features
}
/// `features`: print what this build can do — one feature per line, so it is
/// greppable — and where the runtime `providers` list that prunes it further
/// lives (architecture/build-features.md D4).
pub fn features() -> Result<(), Box<dyn Error>> {
for feature in build_features() {
println!("{feature}");
}
let path = config_dir()
.map(|dir| {
dir.join(crate::settings::SETTINGS_FILE)
.display()
.to_string()
})
.unwrap_or_else(|_| crate::settings::SETTINGS_FILE.to_string());
eprintln!(
"\nProviders can be pruned further at runtime with the `providers` list in {path}; \
names missing above are not in this binary and cannot be enabled there."
);
Ok(())
}
/// The outcome of a `scan` walk, for a concise summary and for tests.
#[cfg(feature = "fs")]
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ScanSummary {
/// Sidecar `.cbd-track.toml` files written this run.
pub written: usize,
/// Audio files skipped because a sidecar already existed.
pub skipped: usize,
}
/// `scan <path> [--capture|--move]`: index a music folder (architecture/cli.md
/// D5). Opens the content store only when a capture/move is requested.
#[cfg(feature = "fs")]
pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> {
if !args.path.is_dir() {
return Err(format!("not a directory: {}", args.path.display()).into());
}
let store = if args.capture || args.move_ {
let tree_root = CrabidyStore::default_tree_root()
.ok_or("--capture/--move need a state directory for the content store")?;
let store_root = CrabidyStore::default_store_root()
.ok_or("--capture/--move need a data directory for the content store")?;
Some(CrabidyStore::open(tree_root, store_root).await?)
} else {
None
};
let summary = scan_dir(&args.path, store.as_ref(), args.move_).await?;
println!(
"scan complete: {} written, {} skipped",
summary.written, summary.skipped
);
Ok(())
}
/// Walks `root` (bounded, hidden entries and symlinks skipped), writing a
/// `<stem>.cbd-track.toml` beside each audio file. With `store` set, each file
/// is ingested (copied, or moved when `move_it`) into the content store and
/// the sidecar points at the store entry; otherwise the sidecar's playable is
/// a relative [`fsdy::Playable::File`]. An existing sidecar is never
/// clobbered. Unreadable entries are warnings, not failures.
#[cfg(feature = "fs")]
pub async fn scan_dir(
root: &Path,
store: Option<&CrabidyStore>,
move_it: bool,
) -> Result<ScanSummary, Box<dyn Error>> {
let mut summary = ScanSummary::default();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let mut read_dir = match tokio::fs::read_dir(&dir).await {
Ok(read_dir) => read_dir,
Err(err) => {
warn!(dir = %dir.display(), "cannot read directory: {err}");
continue;
}
};
loop {
let entry = match read_dir.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(err) => {
warn!(dir = %dir.display(), "error listing directory: {err}");
break;
}
};
let Ok(file_type) = entry.file_type().await else {
continue;
};
if file_type.is_symlink() {
continue;
}
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.starts_with('.') {
continue;
}
let path = entry.path();
if file_type.is_dir() {
stack.push(path);
} else if is_audio_file(&path) {
match scan_file(&dir, &path, store, move_it).await {
Ok(true) => summary.written += 1,
Ok(false) => summary.skipped += 1,
Err(err) => warn!(file = %path.display(), "cannot index file: {err}"),
}
}
}
}
Ok(summary)
}
/// Indexes one audio file. Returns `true` when a sidecar was written, `false`
/// when one already existed (left untouched).
#[cfg(feature = "fs")]
async fn scan_file(
dir: &Path,
file: &Path,
store: Option<&CrabidyStore>,
move_it: bool,
) -> Result<bool, Box<dyn Error>> {
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.ok_or("audio file has no usable name")?
.to_string();
let file_name = file
.file_name()
.and_then(|n| n.to_str())
.ok_or("audio file has no usable name")?
.to_string();
let sidecar = dir.join(format!("{stem}{}", fsdy::TRACK_FILE_SUFFIX));
if tokio::fs::try_exists(&sidecar).await.unwrap_or(false) {
warn!(sidecar = %sidecar.display(), "leaving existing track toml untouched");
return Ok(false);
}
let playable = match store {
Some(store) => {
let name = store.ingest_file(file, move_it).await?;
fsdy::PlayableSpec {
file: None,
url: None,
link: None,
store: Some(name),
skipped: None,
}
}
None => fsdy::PlayableSpec {
file: Some(PathBuf::from(&file_name)),
url: None,
link: None,
store: None,
skipped: None,
},
};
let track_file = fsdy::TrackFile {
title: stem,
artist: String::new(),
duration: None,
album: None,
// A scanned local file has no provider-internal id.
provider_item_id: None,
playable,
};
tokio::fs::write(&sidecar, track_file.to_toml()?).await?;
Ok(true)
}
/// Whether `path`'s extension is a known audio extension (case-insensitive).
#[cfg(feature = "fs")]
fn is_audio_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.map(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str()))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn guard_writes_the_role_hash_and_preserves_others() {
let dir = TempDir::new().expect("tempdir");
// Guarding must run top-down; owner first, then queue-owner.
write_role_hash(dir.path(), Role::Owner, "$argon2id$owner".to_string()).expect("owner");
write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string()).expect("qo");
let settings = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner"));
assert_eq!(settings.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
assert!(settings.auth.queue_appender.is_none());
}
#[test]
fn guard_refuses_to_write_a_role_below_an_unguarded_one() {
let dir = TempDir::new().expect("tempdir");
// queue-owner without owner would let anonymous callers outrank it.
let err = write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string())
.expect_err("must refuse");
assert!(err.contains("owner"), "{err}");
// Nothing was written, so the config still loads clean.
assert!(ServerSettings::load(dir.path()).is_ok());
}
#[test]
fn build_features_lists_providers_then_extras() {
let features = build_features();
// Providers first, in BUILT_IN_PROVIDERS order, then the extras.
let providers = crate::settings::BUILT_IN_PROVIDERS;
assert_eq!(&features[..providers.len()], providers);
let extras = &features[providers.len()..];
for extra in extras {
assert!(
["opus", "spectrum", "web-ui"].contains(extra),
"unexpected extra {extra}"
);
}
assert_eq!(extras.contains(&"opus"), cfg!(feature = "opus"));
assert_eq!(extras.contains(&"spectrum"), cfg!(feature = "spectrum"));
assert_eq!(extras.contains(&"web-ui"), cfg!(feature = "web-ui"));
}
/// `scan` must index `.opus` exactly when this build can decode it
/// (architecture/build-features.md D6) — and never change its verdict on
/// the other extensions.
#[cfg(feature = "fs")]
#[test]
fn opus_is_scannable_only_with_the_decoder() {
assert_eq!(
is_audio_file(Path::new("/music/a.opus")),
cfg!(feature = "opus")
);
assert_eq!(
is_audio_file(Path::new("/music/A.OPUS")),
cfg!(feature = "opus")
);
for name in ["a.flac", "a.mp3", "a.m4a", "a.ogg", "a.wav"] {
assert!(is_audio_file(&PathBuf::from(name)), "{name}");
}
for name in ["a.jpg", "a.cbd-track.toml", "a"] {
assert!(!is_audio_file(&PathBuf::from(name)), "{name}");
}
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_walks_past_opus_files_it_cannot_play() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("song.opus"), b"AUDIO").expect("write");
std::fs::write(dir.path().join("other.flac"), b"AUDIO").expect("write");
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
let expected = if cfg!(feature = "opus") { 2 } else { 1 };
assert_eq!(summary.written, expected);
assert_eq!(
dir.path().join("song.cbd-track.toml").is_file(),
cfg!(feature = "opus")
);
assert!(dir.path().join("other.cbd-track.toml").is_file());
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_writes_file_playables_and_skips_existing_tomls() {
let dir = TempDir::new().expect("tempdir");
let sub = dir.path().join("album");
std::fs::create_dir(&sub).expect("mkdir");
std::fs::write(sub.join("song.flac"), b"AUDIO").expect("write");
std::fs::write(dir.path().join("cover.jpg"), b"jpg").expect("write");
std::fs::write(dir.path().join(".hidden.mp3"), b"x").expect("write");
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
assert_eq!(summary.written, 1);
assert_eq!(summary.skipped, 0);
let sidecar = sub.join("song.cbd-track.toml");
assert!(sidecar.is_file());
let text = std::fs::read_to_string(&sidecar).expect("read");
let track = fsdy::TrackFile::parse(&text).expect("parse");
assert_eq!(track.title, "song");
assert_eq!(
track.playable().expect("playable"),
fsdy::Playable::File(PathBuf::from("song.flac"))
);
// A re-scan leaves the hand-written toml untouched.
let again = scan_dir(dir.path(), None, false).await.expect("rescan");
assert_eq!(again.written, 0);
assert_eq!(again.skipped, 1);
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() {
let src = TempDir::new().expect("srcdir");
std::fs::write(src.path().join("a.flac"), b"AUDIO").expect("write");
let store_dir = TempDir::new().expect("storedir");
let store = CrabidyStore::open(
store_dir.path().join("state"),
store_dir.path().join("store"),
)
.await
.expect("open store");
let summary = scan_dir(src.path(), Some(&store), false)
.await
.expect("capture scan");
assert_eq!(summary.written, 1);
// Source stays (copy), the store has the audio, the toml points there.
assert!(src.path().join("a.flac").is_file());
let text = std::fs::read_to_string(src.path().join("a.cbd-track.toml")).expect("read");
let track = fsdy::TrackFile::parse(&text).expect("parse");
match track.playable().expect("playable") {
fsdy::Playable::Store(name) => assert!(store.store_dir().join(&name).is_file()),
other => panic!("expected a store playable, got {other:?}"),
}
}
#[cfg(feature = "fs")]
#[tokio::test]
async fn scan_move_removes_the_source_audio() {
let src = TempDir::new().expect("srcdir");
std::fs::write(src.path().join("b.mp3"), b"BYTES").expect("write");
let store_dir = TempDir::new().expect("storedir");
let store = CrabidyStore::open(
store_dir.path().join("state"),
store_dir.path().join("store"),
)
.await
.expect("open store");
scan_dir(src.path(), Some(&store), true)
.await
.expect("move scan");
// The original audio is gone; only the toml remains beside it.
assert!(!src.path().join("b.mp3").exists());
assert!(src.path().join("b.cbd-track.toml").is_file());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,23 @@
pub mod auth; pub mod auth;
pub mod bookmark_store;
#[cfg(feature = "web-ui")] #[cfg(feature = "web-ui")]
pub mod web; pub mod web;
// Local files and persistent state: the content store behind `/crabidy` and
// `/orphans`, bookmarks/captures, and queue persistence are all written in
// terms of `fsdy`, so they share the `fs` feature
// (architecture/build-features.md D5).
#[cfg(feature = "fs")]
pub mod capture; pub mod capture;
pub mod capture_store; pub mod cli;
#[cfg(feature = "fs")]
pub mod crabidy_store;
#[cfg(feature = "fs")]
pub mod orphans;
pub mod playback; pub mod playback;
pub mod provider; pub mod provider;
pub mod queue_store;
pub mod rpc; pub mod rpc;
pub mod settings; pub mod settings;
#[cfg(feature = "spectrum")]
pub mod spectrum; pub mod spectrum;
use audio_player::PlayerMessage; use audio_player::PlayerMessage;
@ -17,7 +25,7 @@ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue, crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
Track, Track,
}; };
use crabidy_core::{ProviderClient, ProviderError}; use crabidy_core::ProviderError;
use rand::{rng, seq::SliceRandom}; use rand::{rng, seq::SliceRandom};
use std::sync::{atomic::AtomicBool, Arc}; use std::sync::{atomic::AtomicBool, Arc};
use std::time::SystemTime; use std::time::SystemTime;
@ -45,43 +53,55 @@ pub async fn serve(
let config_dir = dirs::config_dir() let config_dir = dirs::config_dir()
.map(|d| d.join("crabidy")) .map(|d| d.join("crabidy"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp")); .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
// Seed a default config (all providers, no auth) on first run so users
// have the full provider list to prune. Best-effort — a write failure just
// means we fall back to the all-enabled default below.
if let Err(err) = settings::ServerSettings::ensure_default(&config_dir) {
warn!("could not write a default crabidy-server.toml: {err}");
}
let server_settings = settings::ServerSettings::load(&config_dir)?; let server_settings = settings::ServerSettings::load(&config_dir)?;
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth)); let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
if authenticator.enabled() { if authenticator.enabled() {
info!("role authorization enabled"); info!("role authorization enabled");
} }
// What this binary can do at all (architecture/build-features.md D4), so
// a tailored build is self-describing in its own log.
info!(features = %cli::build_features().join(" "), "build features");
// A `providers` entry this build cannot mount is a warning, not a startup
// error: the library layer is fail-open, unlike `[auth]`.
for name in server_settings.unavailable_providers() {
warn!(
provider = name,
"crabidy-server.toml lists a provider this binary was not built with; ignoring it"
);
}
let toggles = server_settings.provider_toggles();
let (update_tx, _) = tokio::sync::broadcast::channel(2048); let (update_tx, _) = tokio::sync::broadcast::channel(2048);
let orchestrator = provider::ProviderOrchestrator::init("") let orchestrator = provider::ProviderOrchestrator::build(toggles)
.await .await
.map_err(|err| { .map_err(|err| {
error!("failed to init provider orchestrator: {err}"); error!("failed to init provider orchestrator: {err}");
err err
})?; })?;
// Queue persistence is optional: without a usable queues directory the // Queue persistence rides on the /crabidy store (its `current` folder);
// server runs with an in-memory queue only. // the orchestrator built it, so playback shares the same Arc. Without a
let queue_store = match queue_store::queues_dir() { // state/data directory it is `None` and the queue lives in memory only —
Some(dir) => match queue_store::QueueStore::open(dir).await { // as it always does in a build without the `fs` feature.
Ok(store) => Some(Arc::new(store)), #[cfg(feature = "fs")]
Err(err) => { let crabidy_store = orchestrator.crabidy_store();
warn!("queue persistence disabled: {err}");
None
}
},
None => {
warn!("queue persistence disabled: no config directory");
None
}
};
let playback = playback::Playback::new( let playback = playback::Playback::new(
update_tx.clone(), update_tx.clone(),
orchestrator.provider_tx.clone(), orchestrator.provider_tx.clone(),
queue_store, #[cfg(feature = "fs")]
crabidy_store,
server_settings.audio.device.clone(),
); );
// Reload the persisted current queue before anything can observe or // Reload the persisted current queue before anything can observe or
// mutate state; never starts playback. // mutate state; never starts playback.
#[cfg(feature = "fs")]
playback.restore_current().await; playback.restore_current().await;
let playback_tx = playback.playback_tx.clone(); let playback_tx = playback.playback_tx.clone();
@ -92,12 +112,14 @@ pub async fn serve(
}); });
info!("player message forwarder started"); info!("player message forwarder started");
#[cfg(feature = "spectrum")]
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone()); spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
let crabidy_service = rpc::RpcService::new( let crabidy_service = rpc::RpcService::new(
update_tx, update_tx,
playback.playback_tx.clone(), playback.playback_tx.clone(),
orchestrator.provider_tx.clone(), orchestrator.provider_tx.clone(),
authenticator.enabled(),
); );
orchestrator.run(); orchestrator.run();
info!("provider orchestrator started"); info!("provider orchestrator started");
@ -149,6 +171,7 @@ pub fn build_router(
/// subscribers, and only recomputes when the tap advanced since the /// subscribers, and only recomputes when the tap advanced since the
/// last tick (audio is flowing), emitting a single zero frame when /// last tick (audio is flowing), emitting a single zero frame when
/// playback goes idle so the bars fall rather than freeze. /// playback goes idle so the bars fall rather than freeze.
#[cfg(feature = "spectrum")]
fn spawn_spectrum_task( fn spawn_spectrum_task(
tap: std::sync::Arc<audio_player::SpectrumTap>, tap: std::sync::Arc<audio_player::SpectrumTap>,
update_tx: tokio::sync::broadcast::Sender< update_tx: tokio::sync::broadcast::Sender<
@ -249,6 +272,13 @@ pub struct PendingResolve {
kind: ResolveKind, kind: ResolveKind,
/// Tracks applied so far; an op finishing at zero is worth a warning. /// Tracks applied so far; an op finishing at zero is worth a warning.
applied: usize, applied: usize,
/// Set once a chunk of this op makes a track current — the op should be
/// driving the initial playback start — and cleared once playback is
/// confirmed started ([`Self::mark_started`]). While set, the playback
/// loop (re)starts an idle player as later chunks arrive, so an unplayable
/// or very short head track that ran the player dry before more tracks
/// resolved does not strand the queue stopped.
wants_start: bool,
/// Shared with the op's forwarder task: set on cancellation so the /// Shared with the op's forwarder task: set on cancellation so the
/// forwarder drops the chunk receiver, which stops the provider fetch. /// forwarder drops the chunk receiver, which stops the provider fetch.
cancelled: Arc<AtomicBool>, cancelled: Arc<AtomicBool>,
@ -259,10 +289,23 @@ impl PendingResolve {
Self { Self {
kind, kind,
applied: 0, applied: 0,
wants_start: false,
cancelled: Arc::new(AtomicBool::new(false)), cancelled: Arc::new(AtomicBool::new(false)),
} }
} }
/// Whether this op should (re)start playback: a chunk made a track current
/// but playback has not been confirmed started yet (see [`Self::apply_chunk`]).
pub fn wants_start(&self) -> bool {
self.wants_start
}
/// Records that playback started for this op, so later chunks only extend
/// the queue and never restart the playing track.
pub fn mark_started(&mut self) {
self.wants_start = false;
}
/// The cancellation flag to hand to this op's forwarder task. /// The cancellation flag to hand to this op's forwarder task.
pub fn cancel_flag(&self) -> Arc<AtomicBool> { pub fn cancel_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.cancelled) Arc::clone(&self.cancelled)
@ -282,10 +325,14 @@ impl PendingResolve {
/// Applies one resolved chunk to the queue and advances this op's /// Applies one resolved chunk to the queue and advances this op's
/// cursor. Returns the track that should start playing, if this chunk /// cursor. Returns the track that should start playing, if this chunk
/// made one current (first chunk of a replace, or any chunk landing in /// made one current (first chunk of a replace, or any chunk landing in
/// an empty queue) — later chunks of the same op never restart playback. /// an empty queue) — later chunks of the same op never restart a playing
/// track. A chunk that makes a track current also sets [`Self::wants_start`],
/// so if that track (or the ones after it) turns out unplayable and runs
/// the player dry before more tracks resolve, the loop retries the start
/// on later chunks instead of leaving the queue stopped.
pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> { pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> {
self.applied += tracks.len(); self.applied += tracks.len();
match self.kind { let started = match self.kind {
ResolveKind::Replace => { ResolveKind::Replace => {
// Only the first chunk replaces; the rest of this op // Only the first chunk replaces; the rest of this op
// extends the fresh queue. // extends the fresh queue.
@ -300,7 +347,11 @@ impl PendingResolve {
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32); self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
queue.insert_tracks(position, tracks) queue.insert_tracks(position, tracks)
} }
};
if started.is_some() {
self.wants_start = true;
} }
started
} }
} }
@ -626,6 +677,8 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
} }
} }
@ -769,6 +822,33 @@ mod tests {
assert!(flag.load(std::sync::atomic::Ordering::Relaxed)); assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
} }
#[test]
fn replace_op_wants_start_until_marked_started() {
let mut q = QueueManager::new();
let mut op = PendingResolve::new(ResolveKind::Replace);
// Nothing applied yet: no start intent.
assert!(!op.wants_start());
// The first chunk makes a track current, so the op now wants to drive
// the initial start — and keeps wanting it across later chunks until
// playback is confirmed, so a skipped/short head that runs the player
// dry before more resolve is retried rather than stranding the queue.
op.apply_chunk(&mut q, &[track(10)]);
assert!(op.wants_start());
op.apply_chunk(&mut q, &[track(11)]);
assert!(op.wants_start(), "still wants start until confirmed");
op.mark_started();
assert!(!op.wants_start());
}
#[test]
fn append_into_playing_queue_never_wants_start() {
let mut q = queue_with(2); // already playing track 0
let mut op = PendingResolve::new(ResolveKind::Append);
op.apply_chunk(&mut q, &[track(10)]);
// Appending behind a playing queue must not (re)start playback.
assert!(!op.wants_start());
}
#[test] #[test]
fn shuffle_insert_keeps_order_unique() { fn shuffle_insert_keeps_order_unique() {
let mut q = queue_with(5); let mut q = queue_with(5);
@ -851,6 +931,10 @@ pub enum ProviderCommand {
/// on `progress_tx`, ending in exactly one `finished` event (with /// on `progress_tx`, ending in exactly one `finished` event (with
/// `error` set on failure). A rejected capture answers with the error /// `error` set on failure). A rejected capture answers with the error
/// and sends no progress events. /// and sends no progress events.
///
/// Only exists with the `fs` feature: captures live in the content store,
/// which that feature brings (architecture/build-features.md D5).
#[cfg(feature = "fs")]
CaptureLibraryNode { CaptureLibraryNode {
path: String, path: String,
name: String, name: String,
@ -869,6 +953,7 @@ impl ProviderCommand {
Self::CreateLibraryNode { .. } => "create_library_node", Self::CreateLibraryNode { .. } => "create_library_node",
Self::RenameLibraryNode { .. } => "rename_library_node", Self::RenameLibraryNode { .. } => "rename_library_node",
Self::DeleteLibraryNode { .. } => "delete_library_node", Self::DeleteLibraryNode { .. } => "delete_library_node",
#[cfg(feature = "fs")]
Self::CaptureLibraryNode { .. } => "capture_library_node", Self::CaptureLibraryNode { .. } => "capture_library_node",
} }
} }
@ -934,9 +1019,13 @@ pub enum PlaybackCommand {
/// `architecture/queue-persistence.md` D6). Handled on the loop so the /// `architecture/queue-persistence.md` D6). Handled on the loop so the
/// snapshot is consistent; the disk write happens on a spawned task and /// snapshot is consistent; the disk write happens on a spawned task and
/// reports through `result_tx`. /// reports through `result_tx`.
///
/// Only exists with the `fs` feature — a saved queue is a store write
/// (architecture/build-features.md D5).
#[cfg(feature = "fs")]
SaveQueue { SaveQueue {
name: String, name: String,
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>, result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
}, },
ToggleShuffle, ToggleShuffle,
ToggleRepeat, ToggleRepeat,
@ -977,6 +1066,7 @@ impl PlaybackCommand {
Self::ResolveFinished { .. } => "resolve_finished", Self::ResolveFinished { .. } => "resolve_finished",
Self::Clear { .. } => "clear", Self::Clear { .. } => "clear",
Self::SetCurrent { .. } => "set_current", Self::SetCurrent { .. } => "set_current",
#[cfg(feature = "fs")]
Self::SaveQueue { .. } => "save_queue", Self::SaveQueue { .. } => "save_queue",
Self::ToggleShuffle => "toggle_shuffle", Self::ToggleShuffle => "toggle_shuffle",
Self::ToggleRepeat => "toggle_repeat", Self::ToggleRepeat => "toggle_repeat",

View File

@ -1,48 +1,62 @@
//! The standalone server binary: stderr tracing plus //! The standalone server binary: a clap-derive CLI
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack //! ([`cbd_cli::ServerCli`]). With no subcommand it runs the server (stderr
//! lives in the library so the bundled `cbd` binary can host it too //! tracing plus [`crabidy_server::serve`] on the fixed listen address),
//! (architecture/cbd-bundle.md D1). //! exactly as before; subcommands cover role setup (`guard`), folder
//! indexing (`scan`), remote control (`library`/`queue`/`global`), and shell
//! completions. The whole server stack lives in the library so the bundled
//! `cbd` binary can host it too (architecture/cbd-bundle.md D1).
use clap::Parser; use cbd_cli::{RemoteCmd, ServerCli, ServerCommand};
use clap::{CommandFactory, Parser};
use crabidy_server::cli;
use tracing_subscriber::{prelude::*, EnvFilter}; use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser)]
#[command(author, version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(clap::Subcommand)]
enum Command {
/// Hash a password for the `[auth]` section of crabidy-server.toml
/// (architecture/roles-auth.md). Reads the password as one line
/// from stdin and prints the PHC string — nothing else, so output
/// can be piped. The password itself is never printed or logged.
HashPassword,
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cli = Cli::parse(); let cli = ServerCli::parse();
if let Some(Command::HashPassword) = cli.command { match cli.command {
return hash_password(); // No subcommand: run the server, exactly as before.
None => {
let _log_guard = init_tracing();
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
Ok(())
}
// Subcommands are one-shot CLI actions: a failure prints a short
// message and exits non-zero, never a color-eyre report chain.
Some(command) => {
if let Err(err) = run_command(&cli.remote, command).await {
eprintln!("error: {err}");
std::process::exit(1);
}
Ok(())
}
} }
let _log_guard = init_tracing();
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
Ok(())
} }
/// Reads one line from stdin and prints its argon2 PHC hash. /// Dispatches a server subcommand.
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn run_command(
let mut password = String::new(); remote: &cbd_cli::RemoteArgs,
std::io::stdin().read_line(&mut password)?; command: ServerCommand,
let password = password.trim_end_matches(['\r', '\n']); ) -> Result<(), Box<dyn std::error::Error>> {
if password.is_empty() { match command {
return Err("empty password".into()); ServerCommand::Guard(args) => cli::guard(args).await,
ServerCommand::Scan(args) => cli::scan(args).await,
ServerCommand::AudioDevices(args) => cli::audio_devices(args.device),
ServerCommand::Features => cli::features(),
ServerCommand::Library(cmd) => {
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await
}
ServerCommand::Queue(cmd) => {
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Queue(cmd)).await
}
ServerCommand::Global(cmd) => {
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Global(cmd)).await
}
ServerCommand::Completions(args) => {
cbd_cli::print_completions(args.shell, &mut ServerCli::command(), "crabidy-server");
Ok(())
}
} }
println!("{}", crabidy_server::auth::hash_password(password)?);
Ok(())
} }
/// Installs the global tracing subscriber. /// Installs the global tracing subscriber.

View File

@ -0,0 +1,378 @@
//! The `/orphans` provider: a store garbage-collection view.
//!
//! `/orphans` surfaces content-store entries that no mounted local file
//! provider references any more (see `architecture/orphans.md`). It is a
//! read-mostly management subtree: each orphan is presented as an editable,
//! deletable, queueable child node, so every client's existing `e`/`d`/queue
//! gestures work unchanged (no proto surface is added).
//!
//! The heavy lifting lives on [`CrabidyStore`] (it owns the store root and the
//! derived index); this provider only supplies the reference roots to walk and
//! translates between library paths and store names.
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use crabidy_core::{
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
ProviderClient, ProviderError,
};
use tracing::warn;
use crate::crabidy_store::{CrabidyStore, StoreError};
/// The single library segment this provider owns.
pub const ORPHANS_PROVIDER_ROOT: &str = "/orphans";
/// Maps a [`StoreError`] to the provider-boundary error: a missing entry or a
/// malformed name is a bad path, a taken/invalid rename target is bad input,
/// everything else is internal.
fn to_provider_error(err: StoreError) -> ProviderError {
match err {
StoreError::NotFound(_) => ProviderError::MalformedPath,
StoreError::NameTaken(_) | StoreError::InvalidName(_) => ProviderError::InvalidInput,
other => {
warn!("orphans store error: {other}");
ProviderError::InternalError
}
}
}
/// The `/orphans` provider.
///
/// Holds the store (for enumeration and mutation) and the disk roots of every
/// mounted local file provider (for the reference scan). Constructed in
/// [`crate::provider::ProviderOrchestrator::init`] only when the store is
/// present.
#[derive(Debug)]
pub struct OrphansProvider {
/// The content store: enumerates orphans and performs rename/delete.
store: Arc<CrabidyStore>,
/// Disk roots to walk for `Playable::Store` references — the `/crabidy`
/// toml tree and (when enabled) the `/fs` root. "Referenced" is defined as
/// reachable through one of these (architecture/orphans.md).
ref_roots: Vec<PathBuf>,
}
impl OrphansProvider {
/// Builds the provider over `store`, scanning `ref_roots` for references.
pub fn new(store: Arc<CrabidyStore>, ref_roots: Vec<PathBuf>) -> Self {
Self { store, ref_roots }
}
/// The bare store name addressed by an `/orphans/<seg>` path (its single
/// decoded segment); `None` for the bare root, a deeper path, or a segment
/// that is not a legal bare name (empty, `.`, `..`, or containing a
/// separator) — which keeps a crafted path from escaping the store root.
fn entry_name(&self, path: &str) -> Option<String> {
let rest = path
.strip_prefix(ORPHANS_PROVIDER_ROOT)?
.strip_prefix('/')?;
if rest.is_empty() || rest.contains('/') {
return None;
}
let name = crabidy_core::decode_segment(rest);
if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
return None;
}
Some(name)
}
/// The library path of the orphan named `name`.
fn entry_path(name: &str) -> String {
crabidy_core::join_path(ORPHANS_PROVIDER_ROOT, &crabidy_core::encode_segment(name))
}
/// The `/orphans` root node, its children recomputed from the current
/// orphan set.
async fn root_node(&self) -> Result<LibraryNode, ProviderError> {
let orphans = self
.store
.list_orphans(&self.ref_roots)
.await
.map_err(to_provider_error)?;
let children = orphans
.into_iter()
.map(|orphan| {
let mut child =
LibraryNodeChild::new(Self::entry_path(&orphan.name), orphan.name, true);
// Reuse the existing rename/delete/queue gestures: an orphan is
// an editable, deletable, queueable, already-captured child.
child.is_editable = true;
child.is_deletable = true;
child.is_downloadable = false;
child.is_captured = true;
child
})
.collect();
Ok(LibraryNode {
path: ORPHANS_PROVIDER_ROOT.to_string(),
title: "orphans".to_string(),
children,
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
})
}
/// A single orphan's node: childless, queueable, carrying its one track.
async fn entry_node(&self, path: &str, name: &str) -> Result<LibraryNode, ProviderError> {
let track = self
.store
.orphan_track(name, path)
.await
.map_err(to_provider_error)?;
Ok(LibraryNode {
path: path.to_string(),
title: name.to_string(),
children: Vec::new(),
parent: Some(ORPHANS_PROVIDER_ROOT.to_string()),
tracks: vec![track],
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: true,
})
}
}
#[async_trait]
impl ProviderClient for OrphansProvider {
/// Not constructed from a config string — built by the orchestrator with a
/// store handle. Present only to satisfy the trait; never called.
async fn init(_raw_toml_settings: &str) -> Result<Self, ProviderError> {
Err(ProviderError::NotSupported)
}
fn settings(&self) -> String {
String::new()
}
/// Always false: orphans are addressed as nodes. The one track per orphan
/// is reached by resolving the node (the default `resolve_tracks_into`
/// walk), not by a track path.
fn is_track_path(&self, _path: &str) -> bool {
false
}
/// Resolves an orphan's audio to its store file path (a local file, exactly
/// like a resolved `Playable::Store`), so the walked-out track still plays.
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
let name = self
.entry_name(track_path)
.ok_or(ProviderError::MalformedPath)?;
let url = self
.store
.orphan_url(&name)
.await
.map_err(to_provider_error)?;
Ok(vec![url])
}
/// Track metadata for a single orphan (from the sidecar's first provider
/// entry); `is_captured` is true.
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
let name = self
.entry_name(track_path)
.ok_or(ProviderError::MalformedPath)?;
self.store
.orphan_track(&name, track_path)
.await
.map_err(to_provider_error)
}
/// A minimal `/orphans` root; children are discovered via
/// [`Self::get_lib_node`] (listing needs async store access).
fn get_lib_root(&self) -> LibraryNode {
LibraryNode {
path: ORPHANS_PROVIDER_ROOT.to_string(),
title: "orphans".to_string(),
children: Vec::new(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// The `/orphans` root lists one child per current orphan; an
/// `/orphans/<seg>` path returns that orphan's single-track node.
/// Recomputes the orphan set on every call (no cache).
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
if path == ORPHANS_PROVIDER_ROOT {
return self.root_node().await;
}
let name = self.entry_name(path).ok_or(ProviderError::MalformedPath)?;
self.entry_node(path, &name).await
}
/// The root is not creatable.
async fn create_lib_node(
&self,
_parent_path: &str,
_title: &str,
) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
/// Renames an orphan's store files (audio + sidecar), keeping the index in
/// sync; returns the node at its new `/orphans/<encode(new)>` path.
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<LibraryNode, ProviderError> {
let old = self.entry_name(path).ok_or(ProviderError::NotSupported)?;
self.store
.rename_orphan(&old, new_title)
.await
.map_err(to_provider_error)?;
let new_path = Self::entry_path(new_title.trim());
let name = self
.entry_name(&new_path)
.ok_or(ProviderError::InvalidInput)?;
self.entry_node(&new_path, &name).await
}
/// Deletes an orphan's audio file and sidecar; returns the refreshed
/// `/orphans` root. Idempotent.
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let name = self.entry_name(path).ok_or(ProviderError::NotSupported)?;
self.store
.delete_orphan(&name)
.await
.map_err(to_provider_error)?;
self.root_node().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crabidy_store::{ProviderEntry, StoreSidecar};
use std::path::Path;
use tempfile::TempDir;
async fn open_store() -> (Arc<CrabidyStore>, TempDir) {
let dir = TempDir::new().expect("tempdir");
let store = CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
.await
.expect("open store");
(Arc::new(store), dir)
}
/// Writes a store entry (audio + sidecar) directly, bypassing capture.
async fn write_entry(store: &CrabidyStore, name: &str, hash: &str, id: &str, title: &str) {
let root = store.store_dir();
tokio::fs::write(root.join(name), b"AUDIO")
.await
.expect("audio");
let sidecar = StoreSidecar {
hash: hash.to_string(),
providers: vec![ProviderEntry {
provider: "tidal".to_string(),
id: id.to_string(),
title: title.to_string(),
artist: "artist".to_string(),
duration: Some(123),
album: None,
aliases: Vec::new(),
}],
};
let text = toml::to_string_pretty(&sidecar).expect("ser");
tokio::fs::write(root.join(format!("{name}.cbd-store.toml")), text)
.await
.expect("sidecar");
}
/// Writes a `.cbd-track.toml` under `dir` that references store `name`.
async fn write_store_reference(dir: &Path, file: &str, name: &str) {
tokio::fs::create_dir_all(dir).await.expect("mkdir");
let toml = format!("title = \"ref\"\n\n[playable]\nstore = \"{name}\"\n");
tokio::fs::write(dir.join(file), toml)
.await
.expect("ref toml");
}
#[tokio::test]
async fn orphans_are_store_entries_no_reference_reaches() {
let (store, dir) = open_store().await;
write_entry(&store, "a.flac", "blake3:a", "1", "A").await;
write_entry(&store, "b.flac", "blake3:b", "2", "B").await;
// A reference under a scanned root rescues b.flac only.
let ref_root = dir.path().join("tree");
write_store_reference(&ref_root, "0001 b.cbd-track.toml", "b.flac").await;
let provider = OrphansProvider::new(store, vec![ref_root]);
let root = provider.get_lib_node("/orphans").await.expect("root");
let names: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
assert_eq!(names, vec!["a.flac"], "only the unreferenced entry orphans");
let child = &root.children[0];
assert!(child.is_editable && child.is_deletable && child.is_queable && child.is_captured);
assert!(!child.is_downloadable);
}
#[tokio::test]
async fn a_leaf_carries_one_captured_track_that_resolves_to_store_audio() {
let (store, _dir) = open_store().await;
write_entry(&store, "song.flac", "blake3:s", "9", "Song").await;
let provider = OrphansProvider::new(store.clone(), vec![]);
let path = OrphansProvider::entry_path("song.flac");
let node = provider.get_lib_node(&path).await.expect("leaf");
assert!(node.children.is_empty());
assert_eq!(node.tracks.len(), 1);
assert!(node.tracks[0].is_captured);
assert_eq!(node.tracks[0].title, "Song");
let urls = provider.get_urls_for_track(&path).await.expect("urls");
let expected = store.store_dir().join("song.flac");
assert_eq!(urls, vec![expected.to_str().unwrap().to_string()]);
}
#[tokio::test]
async fn rename_moves_both_files_and_delete_removes_them() {
let (store, _dir) = open_store().await;
write_entry(&store, "old.flac", "blake3:x", "7", "Old").await;
let provider = OrphansProvider::new(store.clone(), vec![]);
let path = OrphansProvider::entry_path("old.flac");
let renamed = provider
.rename_lib_node(&path, "new.flac")
.await
.expect("rename");
assert_eq!(renamed.title, "new.flac");
assert!(store.store_dir().join("new.flac").is_file());
assert!(!store.store_dir().join("old.flac").exists());
assert!(store.store_dir().join("new.flac.cbd-store.toml").is_file());
assert!(!store.store_dir().join("old.flac.cbd-store.toml").exists());
let new_path = OrphansProvider::entry_path("new.flac");
provider.delete_lib_node(&new_path).await.expect("delete");
assert!(!store.store_dir().join("new.flac").exists());
assert!(!store.store_dir().join("new.flac.cbd-store.toml").exists());
}
#[tokio::test]
async fn unknown_and_traversal_paths_are_malformed() {
let (store, _dir) = open_store().await;
let provider = OrphansProvider::new(store, vec![]);
// A never-created entry.
assert!(matches!(
provider.get_lib_node("/orphans/ghost.flac").await,
Err(ProviderError::MalformedPath)
));
// A traversal attempt decodes to a rejected segment.
assert!(provider.entry_name("/orphans/..%2Fetc").is_none());
assert!(provider.entry_name("/orphans/a/b").is_none());
assert!(provider.entry_name("/orphans").is_none());
}
}

View File

@ -1,4 +1,10 @@
use crate::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError}; // Queue persistence and saved queues live in the content store, which the
// `fs` feature brings (architecture/build-features.md D5). Without it the
// queue is in memory only.
#[cfg(feature = "fs")]
use crate::capture::CaptureError;
#[cfg(feature = "fs")]
use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot};
use crate::{PendingResolve, QueueManager, ResolveKind}; use crate::{PendingResolve, QueueManager, ResolveKind};
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
use audio_player::Player; use audio_player::Player;
@ -8,10 +14,14 @@ use crabidy_core::proto::crabidy::{
Queue as ProtoQueue, QueueTrack, Track, TrackPosition, Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
}; };
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; #[cfg(feature = "fs")]
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument}; use std::sync::Arc;
use std::sync::Mutex;
#[cfg(feature = "fs")]
use tracing::info;
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
pub struct Playback { pub struct Playback {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
@ -25,11 +35,13 @@ pub struct Playback {
/// touches this map (same single-writer discipline as `queue`). /// touches this map (same single-writer discipline as `queue`).
pending: Mutex<HashMap<u64, PendingResolve>>, pending: Mutex<HashMap<u64, PendingResolve>>,
next_op_id: AtomicU64, next_op_id: AtomicU64,
/// `None` when queue persistence is disabled (no usable queues /// `None` when queue persistence is disabled (no usable state
/// directory) — the queue then lives in memory only. /// directory) — the queue then lives in memory only.
store: Option<Arc<QueueStore>>, #[cfg(feature = "fs")]
store: Option<Arc<CrabidyStore>>,
/// Feeds the persister task; latest snapshot wins, so the loop never /// Feeds the persister task; latest snapshot wins, so the loop never
/// waits on disk (architecture/queue-persistence.md D4). /// waits on disk (architecture/queue-persistence.md D4).
#[cfg(feature = "fs")]
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>, persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
pub player: Player, pub player: Player,
} }
@ -38,13 +50,15 @@ impl Playback {
pub fn new( pub fn new(
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
provider_tx: flume::Sender<ProviderMessage>, provider_tx: flume::Sender<ProviderMessage>,
store: Option<Arc<QueueStore>>, #[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>,
audio_device: Option<String>,
) -> Self { ) -> Self {
let (playback_tx, playback_rx) = flume::bounded(64); let (playback_tx, playback_rx) = flume::bounded(64);
let queue = Mutex::new(QueueManager::new()); let queue = Mutex::new(QueueManager::new());
let state = Mutex::new(PlayState::Stopped); let state = Mutex::new(PlayState::Stopped);
#[cfg(feature = "fs")]
let (persist_tx, _) = tokio::sync::watch::channel(None); let (persist_tx, _) = tokio::sync::watch::channel(None);
let player = Player::default(); let player = Player::new(audio_device);
Self { Self {
update_tx, update_tx,
provider_tx, provider_tx,
@ -54,7 +68,9 @@ impl Playback {
state, state,
pending: Mutex::new(HashMap::new()), pending: Mutex::new(HashMap::new()),
next_op_id: AtomicU64::new(0), next_op_id: AtomicU64::new(0),
#[cfg(feature = "fs")]
store, store,
#[cfg(feature = "fs")]
persist_tx, persist_tx,
player, player,
} }
@ -64,6 +80,7 @@ impl Playback {
/// shuffle/repeat. Never starts playback — a restarted server stays /// shuffle/repeat. Never starts playback — a restarted server stays
/// silent. Call before [`Self::run`] so nothing observes the empty /// silent. Call before [`Self::run`] so nothing observes the empty
/// queue first. /// queue first.
#[cfg(feature = "fs")]
pub async fn restore_current(&self) { pub async fn restore_current(&self) {
let Some(store) = &self.store else { let Some(store) = &self.store else {
return; return;
@ -95,8 +112,9 @@ impl Playback {
} }
pub fn run(self) { pub fn run(self) {
#[cfg(feature = "fs")]
if let Some(store) = &self.store { if let Some(store) = &self.store {
queue_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe()); crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
} }
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await { while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
@ -147,6 +165,9 @@ impl Playback {
repeat: queue.repeat, repeat: queue.repeat,
shuffle: queue.shuffle, shuffle: queue.shuffle,
}), }),
// Stamped by the RPC handler, which owns the auth
// switch; the playback loop only knows queue state.
auth_enabled: false,
} }
}; };
trace!(?response, "sending init response"); trace!(?response, "sending init response");
@ -252,6 +273,7 @@ impl Playback {
self.play(track).await; self.play(track).await;
} }
#[cfg(feature = "fs")]
PlaybackCommand::SaveQueue { name, result_tx } => { PlaybackCommand::SaveQueue { name, result_tx } => {
debug!(name, "saving the queue"); debug!(name, "saving the queue");
// Snapshot on the loop (single-writer discipline), write on // Snapshot on the loop (single-writer discipline), write on
@ -273,8 +295,8 @@ impl Playback {
tokio::spawn( tokio::spawn(
async move { async move {
let result = match &store { let result = match &store {
Some(store) => store.save(&name, &snapshot).await, Some(store) => store.save_snapshot(&name, &snapshot.tracks).await,
None => Err(SaveQueueError::Disabled), None => Err(CaptureError::Disabled),
}; };
if let Err(err) = &result { if let Err(err) = &result {
warn!(name, "cannot save queue: {err}"); warn!(name, "cannot save queue: {err}");
@ -326,12 +348,25 @@ impl Playback {
*state *state
}; };
debug!(?state, "toggling play"); debug!(?state, "toggling play");
if state == PlayState::Playing { match state {
if let Err(err) = self.player.pause().await { PlayState::Playing => {
warn!("pause failed: {err:?}"); if let Err(err) = self.player.pause().await {
warn!("pause failed: {err:?}");
}
}
PlayState::Paused => {
if let Err(err) = self.player.unpause().await {
warn!("unpause failed: {err:?}");
}
}
// Stopped/idle: nothing is loaded in the player (e.g. right
// after a restart restored the queue without autoplay), so
// there is nothing to unpause. Load and play the current
// queue track, exactly as `SetCurrent` does — a no-op when
// the queue is empty.
_ => {
self.play(self.current_track()).await;
} }
} else if let Err(err) = self.player.unpause().await {
warn!("unpause failed: {err:?}");
} }
} }
@ -405,8 +440,23 @@ impl Playback {
PlaybackCommand::RestartTrack => { PlaybackCommand::RestartTrack => {
debug!("restarting current track"); debug!("restarting current track");
if let Err(err) = self.player.restart().await { let state = {
warn!("restart failed: {err:?}"); let Ok(state) = self.state.lock() else {
error!("play state lock poisoned");
return;
};
*state
};
// With a track loaded (playing or paused) restart it from the
// top. When nothing is loaded (queue restored without autoplay)
// there is no source to restart, so start the current queue
// track instead.
if matches!(state, PlayState::Playing | PlayState::Paused) {
if let Err(err) = self.player.restart().await {
warn!("restart failed: {err:?}");
}
} else {
self.play(self.current_track()).await;
} }
} }
@ -437,17 +487,25 @@ impl Playback {
/// Registers a pending resolve operation and spawns its forwarder task. /// Registers a pending resolve operation and spawns its forwarder task.
/// ///
/// The forwarder resolves `paths` one after the other (preserving the /// The forwarder resolves `paths` with an *exponential read-ahead*: a
/// request's path order): for each path it sends /// concurrency window that starts at 1 and doubles (1, 2, 4, 8, 16, then
/// `ProviderCommand::ResolveTracks` with a fresh bounded chunk channel /// steady 16) after each path completes. It keeps up to `window` paths
/// and forwards every chunk to the playback loop as /// resolving at once (each via `ProviderCommand::ResolveTracks` on its own
/// `PlaybackCommand::ApplyResolvedChunk`; after the last path it sends /// bounded chunk channel) but forwards chunks to the playback loop as
/// `ResolveFinished`. When the op's cancellation flag is set, the /// `PlaybackCommand::ApplyResolvedChunk` in strict path order, so the queue
/// forwarder drops the chunk receiver instead — the provider's next /// keeps the requested order and the first chunk still lands — and starts
/// send fails and the fetch stops. Queue state is never touched here: /// playback — as fast as a single resolve. The first track therefore plays
/// mutations happen only when the loop processes the forwarded /// as soon as one resolve yields it, while the window fills the queue far
/// commands. An immediate `Queue` broadcast (unchanged tracks, /// ahead of playback: a very short or skipped leading track cannot outrun
/// `resolving = true`) gives clients instant feedback. /// resolution and cause a silent stall (architecture/progressive-queueing.md D8).
///
/// After the last path it sends `ResolveFinished`. When the op's
/// cancellation flag is set, the forwarder stops launching resolves and
/// drops its chunk receivers — each provider's next send fails and the
/// fetch stops. Queue state is never touched here: mutations happen only
/// when the loop processes the forwarded commands. An immediate `Queue`
/// broadcast (unchanged tracks, `resolving = true`) gives clients instant
/// feedback.
fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) { fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) {
let op = PendingResolve::new(kind); let op = PendingResolve::new(kind);
let cancelled = op.cancel_flag(); let cancelled = op.cancel_flag();
@ -473,26 +531,45 @@ impl Playback {
let playback_tx = self.playback_tx.clone(); let playback_tx = self.playback_tx.clone();
tokio::spawn( tokio::spawn(
async move { async move {
for path in &paths { // Exponential read-ahead window: 1, 2, 4, 8, 16, then steady.
const MAX_WINDOW: usize = 16;
let mut window = 1usize;
let mut next = 0usize;
let mut inflight: VecDeque<(String, flume::Receiver<Vec<Track>>)> = VecDeque::new();
'resolve: loop {
if cancelled.load(Ordering::Relaxed) { if cancelled.load(Ordering::Relaxed) {
break; break;
} }
let (chunk_tx, chunk_rx) = flume::bounded(4); // Top the window up with fresh concurrent resolves. Each
let message = ProviderMessage::new(ProviderCommand::ResolveTracks { // resolves in the background into its own bounded channel
path: path.clone(), // while we drain the oldest one below.
chunk_tx, while inflight.len() < window && next < paths.len() {
}); let path = paths[next].clone();
if provider_tx.send_async(message).await.is_err() { next += 1;
error!("provider channel closed"); let (chunk_tx, chunk_rx) = flume::bounded(4);
break; let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
path: path.clone(),
chunk_tx,
});
if provider_tx.send_async(message).await.is_err() {
error!("provider channel closed");
break 'resolve;
}
inflight.push_back((path, chunk_rx));
} }
// Drain the oldest (lowest path index) resolve to
// completion before the next, so chunks reach the loop in
// request order even though resolution ran concurrently.
let Some((path, chunk_rx)) = inflight.pop_front() else {
break;
};
let mut forwarded = 0usize; let mut forwarded = 0usize;
while let Ok(tracks) = chunk_rx.recv_async().await { while let Ok(tracks) = chunk_rx.recv_async().await {
// On cancellation this loop exits and drops // On cancellation this drops chunk_rx (and, on the next
// chunk_rx; the provider's next send fails and the // iteration, the rest of `inflight`); each provider's
// fetch stops. // next send then fails and its fetch stops.
if cancelled.load(Ordering::Relaxed) { if cancelled.load(Ordering::Relaxed) {
break; break 'resolve;
} }
forwarded += tracks.len(); forwarded += tracks.len();
let apply = PlaybackCommand::ApplyResolvedChunk { op_id, tracks }; let apply = PlaybackCommand::ApplyResolvedChunk { op_id, tracks };
@ -508,6 +585,7 @@ impl Playback {
if forwarded == 0 && !cancelled.load(Ordering::Relaxed) { if forwarded == 0 && !cancelled.load(Ordering::Relaxed) {
warn!(path, "path resolved to no playable tracks"); warn!(path, "path resolved to no playable tracks");
} }
window = (window * 2).min(MAX_WINDOW);
} }
// Always reported — also for cancelled or empty ops — so // Always reported — also for cancelled or empty ops — so
// the pending map can never leak a stuck resolving flag. // the pending map can never leak a stuck resolving flag.
@ -520,15 +598,17 @@ impl Playback {
/// Applies one chunk to the queue for pending op `op_id`, broadcasts /// Applies one chunk to the queue for pending op `op_id`, broadcasts
/// the grown queue, and starts playback when the chunk made a track /// the grown queue, and starts playback when the chunk made a track
/// current. Chunks for an unknown op id (finished or cancelled) are /// current — or when this op still wants to start but its earlier attempt
/// dropped silently. /// ran the player dry on skipped/short head tracks before more resolved.
/// Chunks for an unknown op id (finished or cancelled) are dropped
/// silently.
async fn apply_resolved_chunk(&self, op_id: u64, tracks: Vec<Track>) { async fn apply_resolved_chunk(&self, op_id: u64, tracks: Vec<Track>) {
let track = { let attempt = {
let Ok(mut queue) = self.queue.lock() else { let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned"); error!("queue lock poisoned");
return; return;
}; };
let track = { let (designated, wants_start) = {
let Ok(mut pending) = self.pending.lock() else { let Ok(mut pending) = self.pending.lock() else {
error!("pending ops lock poisoned"); error!("pending ops lock poisoned");
return; return;
@ -537,12 +617,32 @@ impl Playback {
trace!(op_id, "dropping chunk for a finished or cancelled op"); trace!(op_id, "dropping chunk for a finished or cancelled op");
return; return;
}; };
op.apply_chunk(&mut queue, &tracks) let designated = op.apply_chunk(&mut queue, &tracks);
(designated, op.wants_start())
}; };
self.broadcast_queue(&queue); self.broadcast_queue(&queue);
track // Start on the track this chunk made current; failing that, if the
// op still wants to start (a prior attempt found nothing playable
// yet), retry from the current position — `next_playable_urls`
// advances past skipped/unplayable heads to the first track that
// has now resolved.
designated.or_else(|| {
if wants_start {
queue.current_track()
} else {
None
}
})
}; };
self.play_if_some(track).await; // Confirming the start clears the op's `wants_start`, so later chunks
// only extend the queue and never restart the playing track.
if self.play(attempt).await {
if let Ok(mut pending) = self.pending.lock() {
if let Some(op) = pending.get_mut(&op_id) {
op.mark_started();
}
}
}
} }
/// Removes the finished op and broadcasts the final `Queue` snapshot /// Removes the finished op and broadcasts the final `Queue` snapshot
@ -610,6 +710,12 @@ impl Playback {
/// when persistence is disabled. Latest snapshot wins, so calling this /// when persistence is disabled. Latest snapshot wins, so calling this
/// on every mutation is free of backpressure (the persister skips /// on every mutation is free of backpressure (the persister skips
/// writes for unchanged snapshots). /// writes for unchanged snapshots).
#[cfg(not(feature = "fs"))]
fn send_persist_snapshot(&self, _queue: &QueueManager) {}
/// Hands the queue's persistable state to the persister task; a no-op
/// when persistence is disabled.
#[cfg(feature = "fs")]
fn send_persist_snapshot(&self, queue: &QueueManager) { fn send_persist_snapshot(&self, queue: &QueueManager) {
if self.store.is_none() { if self.store.is_none() {
return; return;
@ -646,6 +752,19 @@ impl Playback {
} }
} }
/// The queue's current track, or `None` when the queue is empty (or its
/// lock is poisoned). The resume-style controls (`TogglePlay`,
/// `RestartTrack`) use it to load what a restored/idle queue points at.
fn current_track(&self) -> Option<Track> {
match self.queue.lock() {
Ok(queue) => queue.current_track(),
Err(_) => {
error!("queue lock poisoned");
None
}
}
}
/// Plays the given track if there is one, otherwise stops the player. /// Plays the given track if there is one, otherwise stops the player.
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))] #[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
async fn play_or_stop(&self, track: Option<Track>) { async fn play_or_stop(&self, track: Option<Track>) {
@ -656,13 +775,6 @@ impl Playback {
} }
} }
/// Plays the given track if there is one; does nothing otherwise.
async fn play_if_some(&self, track: Option<Track>) {
if track.is_some() {
self.play(track).await;
}
}
/// Finds the stream URLs of the first playable track, starting at /// Finds the stream URLs of the first playable track, starting at
/// `track` and advancing the queue past unplayable ones. Tracks marked /// `track` and advancing the queue past unplayable ones. Tracks marked
/// `is_skipped` (captures recorded their source as uncapturable) are /// `is_skipped` (captures recorded their source as uncapturable) are
@ -715,20 +827,26 @@ impl Playback {
/// Starts playback of the given track, skipping past unplayable ones /// Starts playback of the given track, skipping past unplayable ones
/// (see [`Self::next_playable_urls`]); stops the player when nothing /// (see [`Self::next_playable_urls`]); stops the player when nothing
/// in the queue is playable. /// in the queue is playable.
///
/// Returns `true` when a playable track was found and handed to the
/// player. A device error while starting is logged but still counts as
/// started — retrying the same track would not help. Returns `false` when
/// there was nothing to play or nothing playable remained (the player is
/// then stopped), so the caller can retry once more tracks resolve.
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))] #[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
async fn play(&self, track: Option<Track>) { async fn play(&self, track: Option<Track>) -> bool {
let Some(track) = track else { let Some(track) = track else {
debug!("nothing to play"); debug!("nothing to play");
return; return false;
}; };
let Some(urls) = self.next_playable_urls(track).await else { let Some(urls) = self.next_playable_urls(track).await else {
self.stop_player().await; self.stop_player().await;
return; return false;
}; };
{ {
let Ok(queue) = self.queue.lock() else { let Ok(queue) = self.queue.lock() else {
error!("queue lock poisoned"); error!("queue lock poisoned");
return; return false;
}; };
// Current-track moves (Next/Prev/SetCurrent/skips) change the // Current-track moves (Next/Prev/SetCurrent/skips) change the
// persisted position without a queue broadcast. // persisted position without a queue broadcast.
@ -742,12 +860,14 @@ impl Playback {
if let Err(err) = self.player.play(&urls[0]).await { if let Err(err) = self.player.play(&urls[0]).await {
error!("player failed to start track: {err:?}"); error!("player failed to start track: {err:?}");
} }
true
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(feature = "fs")]
use tempfile::TempDir; use tempfile::TempDir;
fn track(i: usize) -> Track { fn track(i: usize) -> Track {
@ -758,29 +878,40 @@ mod tests {
duration: None, duration: None,
album: None, album: None,
is_skipped: false, is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
} }
} }
async fn store_in(dir: &TempDir) -> Arc<QueueStore> { #[cfg(feature = "fs")]
async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> {
Arc::new( Arc::new(
QueueStore::open(dir.path().join("queues")) CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
.await .await
.expect("open store"), .expect("open store"),
) )
} }
fn playback_with(store: Option<Arc<QueueStore>>) -> Playback { fn playback_with(#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>) -> Playback {
let (update_tx, _) = tokio::sync::broadcast::channel(64); let (update_tx, _) = tokio::sync::broadcast::channel(64);
let (provider_tx, _provider_rx) = flume::bounded(16); let (provider_tx, _provider_rx) = flume::bounded(16);
Playback::new(update_tx, provider_tx, store) Playback::new(
update_tx,
provider_tx,
#[cfg(feature = "fs")]
store,
None,
)
} }
#[cfg(feature = "fs")]
fn fill_queue(playback: &Playback, n: usize) { fn fill_queue(playback: &Playback, n: usize) {
let tracks: Vec<Track> = (0..n).map(track).collect(); let tracks: Vec<Track> = (0..n).map(track).collect();
let mut queue = playback.queue.lock().expect("queue lock"); let mut queue = playback.queue.lock().expect("queue lock");
let _ = queue.replace_with_tracks(&tracks); let _ = queue.replace_with_tracks(&tracks);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn restore_fills_the_queue_without_starting_playback() { async fn restore_fills_the_queue_without_starting_playback() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -810,6 +941,7 @@ mod tests {
); );
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn restore_survives_an_out_of_range_position() { async fn restore_survives_an_out_of_range_position() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -829,6 +961,7 @@ mod tests {
assert_eq!(queue.current_position(), 0); assert_eq!(queue.current_position(), 0);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn save_queue_command_snapshots_the_live_queue() { async fn save_queue_command_snapshots_the_live_queue() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -849,7 +982,7 @@ mod tests {
.expect("reply") .expect("reply")
.expect("save succeeds"); .expect("save succeeds");
let entries = std::fs::read_dir(store.dir().join("road trip")) let entries = std::fs::read_dir(store.tree_dir().join("road trip"))
.expect("saved queue folder") .expect("saved queue folder")
.filter(|e| { .filter(|e| {
!e.as_ref() !e.as_ref()
@ -862,6 +995,7 @@ mod tests {
assert_eq!(entries, 2); assert_eq!(entries, 2);
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn save_queue_rejects_an_empty_queue() { async fn save_queue_rejects_an_empty_queue() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
@ -874,7 +1008,7 @@ mod tests {
}) })
.await; .await;
let result = result_rx.recv_async().await.expect("reply"); let result = result_rx.recv_async().await.expect("reply");
assert!(matches!(result, Err(SaveQueueError::EmptyQueue))); assert!(matches!(result, Err(CaptureError::BadSource(_))));
} }
#[tokio::test] #[tokio::test]
@ -884,7 +1018,10 @@ mod tests {
// with `None`. The marked tracks are skipped without any provider // with `None`. The marked tracks are skipped without any provider
// round trip (the provider channel is closed — a call would fail, // round trip (the provider channel is closed — a call would fail,
// not hang). // not hang).
let playback = playback_with(None); let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
let tracks: Vec<Track> = (0..3) let tracks: Vec<Track> = (0..3)
.map(|i| Track { .map(|i| Track {
is_skipped: true, is_skipped: true,
@ -903,6 +1040,7 @@ mod tests {
assert!(urls.is_none(), "an all-skipped queue has nothing playable"); assert!(urls.is_none(), "an all-skipped queue has nothing playable");
} }
#[cfg(feature = "fs")]
#[tokio::test] #[tokio::test]
async fn queue_mutations_reach_the_persist_channel() { async fn queue_mutations_reach_the_persist_channel() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");

File diff suppressed because it is too large Load Diff

View File

@ -1,472 +0,0 @@
//! Persisted queues on disk (see `architecture/queue-persistence.md`).
//!
//! Every queue is a folder under the store directory
//! (`<config>/crabidy/queues/`) holding one order-prefixed
//! `*.cbd-track.toml` **link** file per entry, plus a hidden
//! [`STATE_FILE_NAME`] sidecar. The automatically maintained queue lives in
//! [`CURRENT_QUEUE_NAME`]; every other folder is a named save. The same
//! directory is mounted read-only into the library as `/queues` by a second
//! `fsdy` instance — this module is the only writer.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use crabidy_core::proto::crabidy::Track;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
/// The library mount point of the queues directory (second `fsdy`
/// instance, see `architecture/queue-persistence.md` D1).
pub const QUEUES_PROVIDER_ROOT: &str = "/queues";
/// Reserved folder name of the automatically maintained queue.
pub const CURRENT_QUEUE_NAME: &str = "current";
/// Hidden per-queue sidecar carrying [`QueueState`]. Dot-prefixed, so
/// library listings never show it.
pub const STATE_FILE_NAME: &str = ".queue-state.toml";
/// The queues directory: `queues/` inside the crabidy config directory.
/// `None` when the platform has no config directory.
pub fn queues_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("crabidy").join("queues"))
}
/// Everything the playback loop knows about the queue that is worth
/// persisting. Sent through the persister's `watch` channel (latest wins)
/// and written by [`QueueStore`].
#[derive(Clone, Debug, PartialEq)]
pub struct QueueSnapshot {
/// Queue entries in track order (not play order — shuffle order is
/// deliberately not persisted).
pub tracks: Vec<Track>,
/// Index of the current track in `tracks`.
pub current_position: u32,
pub repeat: bool,
pub shuffle: bool,
}
/// The on-disk schema of the [`STATE_FILE_NAME`] sidecar.
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct QueueState {
pub current_position: u32,
pub repeat: bool,
pub shuffle: bool,
}
/// Errors from validating or writing a persisted queue.
///
/// At the RPC boundary: `InvalidName` → `invalid_argument`, `EmptyQueue` →
/// `failed_precondition`, the rest → `internal`. Messages carry names and
/// paths, never file contents.
#[derive(Debug, thiserror::Error)]
pub enum SaveQueueError {
#[error("invalid queue name: {0}")]
InvalidName(&'static str),
#[error("the queue is empty")]
EmptyQueue,
#[error("queue persistence is disabled")]
Disabled,
#[error("cannot write queue: {0}")]
Io(#[from] std::io::Error),
#[error(transparent)]
TrackFile(#[from] fsdy::TrackFileError),
#[error("cannot serialize queue state: {0}")]
State(#[from] toml::ser::Error),
}
/// Reads and writes persisted queue folders. Cheap to clone behind an
/// `Arc`; all I/O is `tokio::fs`.
#[derive(Debug)]
pub struct QueueStore {
dir: PathBuf,
}
impl QueueStore {
/// Opens the store at `dir`, creating the directory (and parents) if
/// missing.
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
tokio::fs::create_dir_all(&dir).await?;
Ok(Self { dir })
}
/// The store directory (what the `/queues` provider instance mounts).
pub fn dir(&self) -> &Path {
&self.dir
}
/// Validates a user-supplied queue name, returning the trimmed name.
///
/// Rejected: empty after trimming, containing `/`, `\` or NUL, starting
/// with a dot (hidden folders are invisible to listings), and the
/// reserved [`CURRENT_QUEUE_NAME`].
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
// The shared fs-provider naming rules, with the auto-persisted
// queue's folder reserved.
fsdy::validate_folder_name(name, &[CURRENT_QUEUE_NAME]).map_err(SaveQueueError::InvalidName)
}
/// Saves `snapshot` as the named queue, overwriting an existing one.
///
/// Validates `name` per [`Self::validate_name`] and rejects an empty
/// snapshot with [`SaveQueueError::EmptyQueue`]. The folder is written
/// to a hidden temp sibling first, then swapped into place (remove old,
/// rename) — a crash can lose the folder, never corrupt it half-written
/// next to intact files.
pub async fn save(&self, name: &str, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
let name = Self::validate_name(name)?;
if snapshot.tracks.is_empty() {
return Err(SaveQueueError::EmptyQueue);
}
self.write_queue_dir(name, snapshot).await
}
/// Persists `snapshot` as the current queue ([`CURRENT_QUEUE_NAME`]).
///
/// Same write path as [`Self::save`] but without name validation and
/// with an empty snapshot allowed — clearing the queue must persist as
/// cleared.
pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
self.write_queue_dir(CURRENT_QUEUE_NAME, snapshot).await
}
/// The shared write path: build the whole folder as a hidden temp
/// sibling, then swap it into place (remove old, rename). A crash can
/// lose the folder, never leave it half-written next to intact files
/// (architecture/queue-persistence.md D3).
async fn write_queue_dir(
&self,
name: &str,
snapshot: &QueueSnapshot,
) -> Result<(), SaveQueueError> {
let tmp = self.dir.join(format!(".tmp-{name}"));
// A leftover temp folder from a crashed or racing write is stale.
if tokio::fs::try_exists(&tmp).await? {
tokio::fs::remove_dir_all(&tmp).await?;
}
tokio::fs::create_dir_all(&tmp).await?;
for (index, track) in snapshot.tracks.iter().enumerate() {
let text = fsdy::TrackFile::from_track(track).to_toml()?;
let file = tmp.join(fsdy::track_file_name(index, &track.title));
tokio::fs::write(file, text).await?;
}
let state = QueueState {
current_position: snapshot.current_position,
repeat: snapshot.repeat,
shuffle: snapshot.shuffle,
};
tokio::fs::write(tmp.join(STATE_FILE_NAME), toml::to_string_pretty(&state)?).await?;
let target = self.dir.join(name);
if tokio::fs::try_exists(&target).await? {
tokio::fs::remove_dir_all(&target).await?;
}
tokio::fs::rename(&tmp, &target).await?;
Ok(())
}
/// Loads the persisted current queue for the startup restore.
///
/// Reads the folder like a library listing (sorted case-insensitively,
/// broken/hidden/foreign entries skipped with warnings) plus the
/// [`QueueState`] sidecar (missing or broken sidecar → default state).
/// `None` when the folder does not exist — a fresh start. Never fails
/// the server; every defect is a warning and degrades to less state.
pub async fn load_current(&self) -> Option<QueueSnapshot> {
let dir = self.dir.join(CURRENT_QUEUE_NAME);
let mut read_dir = match tokio::fs::read_dir(&dir).await {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) => {
warn!(dir = %dir.display(), "cannot read the persisted queue: {err}");
return None;
}
};
// Mirror the provider listing: visible regular `*.cbd-track.toml`
// files, sorted case-insensitively — restore order == listing order.
let mut names: Vec<String> = Vec::new();
loop {
let entry = match read_dir.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(err) => {
warn!(dir = %dir.display(), "error while reading the persisted queue: {err}");
break;
}
};
let is_file = entry
.file_type()
.await
.is_ok_and(|file_type| file_type.is_file());
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
warn!(dir = %dir.display(), "skipping queue entry with non-UTF-8 name");
continue;
};
if is_file && !name.starts_with('.') && name.ends_with(fsdy::TRACK_FILE_SUFFIX) {
names.push(name);
}
}
names.sort_by_key(|name| name.to_lowercase());
let mut tracks = Vec::new();
for name in names {
let file = dir.join(&name);
let text = match tokio::fs::read_to_string(&file).await {
Ok(text) => text,
Err(err) => {
warn!(file = %file.display(), "cannot read queue entry: {err}");
continue;
}
};
match fsdy::TrackFile::parse(&text) {
Ok(track_file) => {
// The same library path the /queues listing would give
// the entry, so non-link playables behave identically.
let lib_path = crabidy_core::join_path(
&crabidy_core::join_path(QUEUES_PROVIDER_ROOT, CURRENT_QUEUE_NAME),
&crabidy_core::encode_segment(&name),
);
tracks.push(track_file.to_track(&lib_path));
}
Err(err) => {
warn!(file = %file.display(), "skipping invalid queue entry: {err}");
}
}
}
let state_file = dir.join(STATE_FILE_NAME);
let state = match tokio::fs::read_to_string(&state_file).await {
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
warn!(file = %state_file.display(), "broken queue state, using defaults: {err}");
QueueState::default()
}),
Err(err) => {
debug!(file = %state_file.display(), "no queue state, using defaults: {err}");
QueueState::default()
}
};
Some(QueueSnapshot {
tracks,
current_position: state.current_position,
repeat: state.repeat,
shuffle: state.shuffle,
})
}
}
/// Spawns the auto-persist task: awaits snapshot changes on `rx`, debounces
/// briefly to coalesce bursts (resolve chunks), skips writes whose snapshot
/// equals the last one written, and rewrites the current queue folder.
/// Write failures are warnings; the task never affects playback. Exits when
/// the sender side is dropped.
pub fn spawn_persister(
store: Arc<QueueStore>,
mut rx: tokio::sync::watch::Receiver<Option<QueueSnapshot>>,
) {
tokio::spawn(async move {
let mut last_written: Option<QueueSnapshot> = None;
while rx.changed().await.is_ok() {
// Debounce: a resolve streams many chunks in quick succession;
// the watch channel keeps only the newest snapshot, so waiting
// briefly coalesces the burst into one write.
tokio::time::sleep(Duration::from_millis(200)).await;
let Some(snapshot) = rx.borrow_and_update().clone() else {
continue;
};
// Broadcasts that only toggled the `resolving` flag carry an
// unchanged snapshot — skip the write.
if last_written.as_ref() == Some(&snapshot) {
continue;
}
match store.persist_current(&snapshot).await {
Ok(()) => last_written = Some(snapshot),
Err(err) => warn!("cannot persist the current queue: {err}"),
}
}
debug!("queue snapshot channel closed, persister exiting");
});
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::Album;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
fn track(i: usize) -> Track {
Track {
path: format!("/tidal/playlists/p/{i}"),
artist: "artist".to_string(),
title: format!("track {i}"),
duration: Some(60 + i as u32),
album: Some(Album {
title: "album".to_string(),
release_date: None,
}),
is_skipped: false,
}
}
fn snapshot(n: usize) -> QueueSnapshot {
QueueSnapshot {
tracks: (0..n).map(track).collect(),
current_position: 0,
repeat: false,
shuffle: false,
}
}
async fn store() -> (QueueStore, TempDir) {
let dir = TempDir::new().expect("tempdir");
let store = QueueStore::open(dir.path().join("queues"))
.await
.expect("open creates the directory");
(store, dir)
}
/// Sorted visible file names of a queue folder.
fn visible_files(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("queue folder")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.collect();
names.sort_by_key(|n| n.to_lowercase());
names
}
#[tokio::test]
async fn save_writes_ordered_link_files_and_the_sidecar() {
let (store, _dir) = store().await;
let mut snap = snapshot(3);
// A queue may hold fs tracks; persisting must link to them too.
snap.tracks[1].path = "/fs/mix/song.cbd-track.toml".to_string();
store.save("road trip", &snap).await.expect("save");
let queue_dir = store.dir().join("road trip");
let names = visible_files(&queue_dir);
assert_eq!(names.len(), 3);
for (i, name) in names.iter().enumerate() {
assert!(name.starts_with(&format!("{:04} ", i + 1)), "{name}");
let text = std::fs::read_to_string(queue_dir.join(name)).expect("read entry");
let file = fsdy::TrackFile::parse(&text).expect("entry parses");
// The listing rewrite restores the original track exactly.
assert_eq!(file.to_track("/queues/irrelevant"), snap.tracks[i]);
}
assert!(
queue_dir.join(STATE_FILE_NAME).exists(),
"sidecar written (hidden from listings by its dot prefix)"
);
}
#[tokio::test]
async fn save_validates_names_and_rejects_an_empty_queue() {
let (store, _dir) = store().await;
for bad in ["", " ", "a/b", "a\\b", ".hidden", CURRENT_QUEUE_NAME] {
assert!(
matches!(
store.save(bad, &snapshot(1)).await,
Err(SaveQueueError::InvalidName(_))
),
"name {bad:?} must be rejected"
);
}
assert!(matches!(
store.save("fine", &snapshot(0)).await,
Err(SaveQueueError::EmptyQueue)
));
// A valid name is used trimmed.
store
.save(" padded ", &snapshot(1))
.await
.expect("trimmed name saves");
assert!(store.dir().join("padded").is_dir());
}
#[tokio::test]
async fn save_overwrites_an_existing_queue_completely() {
let (store, _dir) = store().await;
store.save("mix", &snapshot(3)).await.expect("first save");
store.save("mix", &snapshot(1)).await.expect("overwrite");
// No stale entries from the longer first save survive.
assert_eq!(visible_files(&store.dir().join("mix")).len(), 1);
}
#[tokio::test]
async fn persist_current_and_load_round_trip() {
let (store, _dir) = store().await;
let snap = QueueSnapshot {
current_position: 2,
repeat: true,
shuffle: true,
..snapshot(4)
};
store.persist_current(&snap).await.expect("persist");
let loaded = store.load_current().await.expect("load");
assert_eq!(loaded, snap);
}
#[tokio::test]
async fn persist_current_accepts_an_empty_queue() {
// Clearing the queue must persist as cleared, not keep yesterday's
// tracks for the next restart.
let (store, _dir) = store().await;
store
.persist_current(&snapshot(2))
.await
.expect("non-empty");
store.persist_current(&snapshot(0)).await.expect("empty");
let loaded = store.load_current().await.expect("load");
assert!(loaded.tracks.is_empty());
}
#[tokio::test]
async fn load_current_without_a_folder_is_a_fresh_start() {
let (store, _dir) = store().await;
assert!(store.load_current().await.is_none());
}
#[tokio::test]
async fn load_current_skips_broken_entries_and_survives_a_broken_sidecar() {
let (store, _dir) = store().await;
store.persist_current(&snapshot(2)).await.expect("persist");
let current = store.dir().join(CURRENT_QUEUE_NAME);
std::fs::write(current.join("0000 broken.cbd-track.toml"), "not [ toml")
.expect("write broken entry");
std::fs::write(current.join(STATE_FILE_NAME), "also not [ toml")
.expect("break the sidecar");
let loaded = store.load_current().await.expect("load");
// The two good tracks load; the broken entry is skipped and the
// broken sidecar degrades to default state instead of failing.
assert_eq!(loaded.tracks.len(), 2);
assert_eq!(loaded.current_position, 0);
}
#[tokio::test]
async fn persister_writes_the_latest_snapshot() {
let (store, _dir) = store().await;
let store = Arc::new(store);
let (tx, rx) = tokio::sync::watch::channel(None);
spawn_persister(Arc::clone(&store), rx);
// A burst: only the newest snapshot matters (latest-wins channel).
tx.send(Some(snapshot(5))).expect("send");
tx.send(Some(snapshot(3))).expect("send");
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
if let Some(loaded) = store.load_current().await {
if loaded.tracks.len() == 3 {
break;
}
}
assert!(
tokio::time::Instant::now() < deadline,
"persister never wrote the latest snapshot"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}

View File

@ -1,5 +1,5 @@
use crate::bookmark_store::CaptureError; #[cfg(feature = "fs")]
use crate::queue_store::SaveQueueError; use crate::capture::CaptureError;
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate, crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
@ -18,6 +18,34 @@ use crabidy_core::proto::crabidy::{
}; };
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::pin::Pin; use std::pin::Pin;
/// The one message both store-backed RPCs answer with in a build that has no
/// content store (architecture/build-features.md D5). `Unimplemented` is the
/// honest status: the method exists on the wire, this build cannot serve it.
#[cfg(not(feature = "fs"))]
const NO_STORE: &str = "this server was built without the `fs` feature, so it has no content \
store: bookmarks, captures, and saved queues are unavailable";
/// Maps a capture/store error to a gRPC status (shared by the capture and
/// queue-save RPCs). Bad input → `invalid_argument`; an existing save name →
/// `already_exists`; precondition failures → `failed_precondition`;
/// everything else → `internal` (logged, message not leaked).
#[cfg(feature = "fs")]
fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status {
match err {
CaptureError::InvalidName(_) | CaptureError::BadSource(_) => {
Status::invalid_argument(err.to_string())
}
CaptureError::Conflict(_) => Status::already_exists(err.to_string()),
CaptureError::TooLarge(_) | CaptureError::Disabled | CaptureError::Unsupported => {
Status::failed_precondition(err.to_string())
}
_ => {
error!("{internal_msg}: {err}");
Status::internal(internal_msg)
}
}
}
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tracing::{debug, error, instrument, trace}; use tracing::{debug, error, instrument, trace};
@ -27,6 +55,10 @@ pub struct RpcService {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
playback_tx: flume::Sender<PlaybackMessage>, playback_tx: flume::Sender<PlaybackMessage>,
provider_tx: flume::Sender<ProviderMessage>, provider_tx: flume::Sender<ProviderMessage>,
/// Whether role authorization is configured. Surfaced verbatim on the
/// anonymously-reachable `Init` response so the web client can offer a
/// login even when it connected as the unauthenticated fallback role.
auth_enabled: bool,
} }
impl RpcService { impl RpcService {
@ -34,11 +66,13 @@ impl RpcService {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
playback_tx: flume::Sender<PlaybackMessage>, playback_tx: flume::Sender<PlaybackMessage>,
provider_tx: flume::Sender<ProviderMessage>, provider_tx: flume::Sender<ProviderMessage>,
auth_enabled: bool,
) -> Self { ) -> Self {
Self { Self {
update_tx, update_tx,
playback_tx, playback_tx,
provider_tx, provider_tx,
auth_enabled,
} }
} }
@ -67,10 +101,14 @@ impl CrabidyService for RpcService {
let (result_tx, result_rx) = flume::bounded(1); let (result_tx, result_rx) = flume::bounded(1);
self.send_playback(PlaybackCommand::Init { result_tx }) self.send_playback(PlaybackCommand::Init { result_tx })
.await?; .await?;
let response = result_rx.recv_async().await.map_err(|err| { let mut response = result_rx.recv_async().await.map_err(|err| {
error!("no reply from playback loop: {err}"); error!("no reply from playback loop: {err}");
Status::internal("playback loop did not reply") Status::internal("playback loop did not reply")
})?; })?;
// The playback loop owns the queue/player state; the auth switch
// is the server's, so it is stamped here rather than threaded
// through the playback command.
response.auth_enabled = self.auth_enabled;
Ok(Response::new(response)) Ok(Response::new(response))
} }
@ -396,93 +434,94 @@ impl CrabidyService for RpcService {
&self, &self,
request: Request<CaptureLibraryNodeRequest>, request: Request<CaptureLibraryNodeRequest>,
) -> Result<Response<CaptureLibraryNodeResponse>, Status> { ) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
let CaptureLibraryNodeRequest { #[cfg(not(feature = "fs"))]
path, {
name, let _ = request;
download, debug!("received capture_library_node request on a store-less build");
} = request.into_inner(); return Err(Status::unimplemented(NO_STORE));
tracing::Span::current().record("path", path.as_str()); }
tracing::Span::current().record("name", name.as_str()); #[cfg(feature = "fs")]
tracing::Span::current().record("download", download); {
debug!("received capture_library_node request"); let CaptureLibraryNodeRequest {
// The walk's progress events fan out to every connected client via
// the update broadcast; the forwarder dies with the walk's terminal
// event (the provider drops the sender).
let (progress_tx, progress_rx) = flume::bounded(64);
let update_tx = self.update_tx.clone();
tokio::spawn(async move {
while let Ok(progress) = progress_rx.recv_async().await {
// No subscribers is normal (e.g. no client connected).
let _ = update_tx.send(StreamUpdate::CaptureProgress(progress));
}
});
let (result_tx, result_rx) = flume::bounded(1);
self.provider_tx
.send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode {
path, path,
name, name,
download, download,
progress_tx, } = request.into_inner();
result_tx, tracing::Span::current().record("path", path.as_str());
})) tracing::Span::current().record("name", name.as_str());
.await tracing::Span::current().record("download", download);
.map_err(|err| { debug!("received capture_library_node request");
error!("provider channel closed: {err}"); // The walk's progress events fan out to every connected client via
Status::internal("provider unavailable") // the update broadcast; the forwarder dies with the walk's terminal
// event (the provider drops the sender).
let (progress_tx, progress_rx) = flume::bounded(64);
let update_tx = self.update_tx.clone();
tokio::spawn(async move {
while let Ok(progress) = progress_rx.recv_async().await {
// No subscribers is normal (e.g. no client connected).
let _ = update_tx.send(StreamUpdate::CaptureProgress(progress));
}
});
let (result_tx, result_rx) = flume::bounded(1);
self.provider_tx
.send_async(ProviderMessage::new(ProviderCommand::CaptureLibraryNode {
path,
name,
download,
progress_tx,
result_tx,
}))
.await
.map_err(|err| {
error!("provider channel closed: {err}");
Status::internal("provider unavailable")
})?;
let result = result_rx.recv_async().await.map_err(|err| {
error!("no reply from provider: {err}");
Status::internal("provider did not reply")
})?; })?;
let result = result_rx.recv_async().await.map_err(|err| { match result {
error!("no reply from provider: {err}"); Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
Status::internal("provider did not reply") Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
})?;
match result {
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
Err(Status::invalid_argument(err.to_string()))
}
Err(
err @ (CaptureError::TooLarge(_)
| CaptureError::Disabled
| CaptureError::Unsupported),
) => Err(Status::failed_precondition(err.to_string())),
Err(err) => {
error!("capture_library_node failed: {err}");
Err(Status::internal("cannot capture the subtree"))
} }
} }
} }
/// Saves the current queue under a name (persisted queues, visible as /// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
/// `/queues/<name>` in the library). /// gesture (architecture/crabidy-store.md D5). A bookmark of the live
/// queue; capturing its audio is the queue `W` gesture, which goes
/// through `capture_library_node` on `/crabidy/current`.
/// ///
/// Error mapping is part of the contract: an invalid name → /// Error mapping is part of the contract: an invalid name →
/// `invalid_argument`; an empty queue or disabled persistence → /// `invalid_argument`; an existing name → `already_exists`; an empty
/// `failed_precondition`; I/O and serialization failures → `internal`. /// queue or disabled persistence → `failed_precondition`; I/O failures →
/// `internal`.
#[instrument(skip(self, request), fields(name))] #[instrument(skip(self, request), fields(name))]
async fn save_queue( async fn save_queue(
&self, &self,
request: Request<SaveQueueRequest>, request: Request<SaveQueueRequest>,
) -> Result<Response<SaveQueueResponse>, Status> { ) -> Result<Response<SaveQueueResponse>, Status> {
let name = request.into_inner().name; #[cfg(not(feature = "fs"))]
tracing::Span::current().record("name", name.as_str()); {
debug!("received save_queue request"); let _ = request;
let (result_tx, result_rx) = flume::bounded(1); debug!("received save_queue request on a store-less build");
self.send_playback(PlaybackCommand::SaveQueue { name, result_tx }) Err(Status::unimplemented(NO_STORE))
.await?; }
let result = result_rx.recv_async().await.map_err(|err| { #[cfg(feature = "fs")]
error!("no reply from playback loop: {err}"); {
Status::internal("playback loop did not reply") let name = request.into_inner().name;
})?; tracing::Span::current().record("name", name.as_str());
match result { debug!("received save_queue request");
Ok(()) => Ok(Response::new(SaveQueueResponse {})), let (result_tx, result_rx) = flume::bounded(1);
Err(err @ SaveQueueError::InvalidName(_)) => { self.send_playback(PlaybackCommand::SaveQueue { name, result_tx })
Err(Status::invalid_argument(err.to_string())) .await?;
} let result = result_rx.recv_async().await.map_err(|err| {
Err(err @ (SaveQueueError::EmptyQueue | SaveQueueError::Disabled)) => { error!("no reply from playback loop: {err}");
Err(Status::failed_precondition(err.to_string())) Status::internal("playback loop did not reply")
} })?;
Err(err) => { match result {
error!("save_queue failed: {err}"); Ok(()) => Ok(Response::new(SaveQueueResponse {})),
Err(Status::internal("cannot save the queue")) Err(err) => Err(capture_error_status(err, "cannot save the queue")),
} }
} }
} }

View File

@ -1,36 +1,153 @@
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`. //! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
//! //!
//! Today this only carries the `[auth]` role hashes //! Carries the enabled-providers list ([`ServerSettings::providers`]) and the
//! (architecture/roles-auth.md). The file is optional — a missing file //! `[auth]` role hashes (architecture/roles-auth.md). The server writes a
//! runs the server open, exactly as before the feature — but a file //! default (all providers, no auth) on first start via
//! that exists and does not parse aborts startup: silently ignoring a //! [`ServerSettings::ensure_default`]. The file is optional — a missing file
//! broken auth config would run an intended-to-be-locked server open //! runs the server open with every provider — but a file that exists and does
//! (fail-closed, quality/roles-auth.md). //! not parse aborts startup: silently ignoring a broken auth config would run
//! an intended-to-be-locked server open (fail-closed, quality/roles-auth.md).
use std::path::Path; use std::path::Path;
use serde::Deserialize; use serde::{Deserialize, Serialize};
/// The server config file name inside the crabidy config directory. /// The server config file name inside the crabidy config directory.
pub const SETTINGS_FILE: &str = "crabidy-server.toml"; pub const SETTINGS_FILE: &str = "crabidy-server.toml";
/// Every provider name the runtime toggle understands, in the order the
/// default config lists them. Each name is a library root (`/tidal`,
/// `/youtube`, `/fyyd`, `/abs`, `/soundcloud`, `/jamendo`, `/fs`, `/crabidy`,
/// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`,
/// and both need the `fs` build feature (architecture/build-features.md D5).
///
/// This is the *vocabulary*, not what this binary can mount — see
/// [`BUILT_IN_PROVIDERS`].
pub const ALL_PROVIDERS: [&str; 9] = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"jamendo",
"fs",
"crabidy",
"orphans",
];
/// The providers **this binary** was built with: [`ALL_PROVIDERS`] filtered by
/// the compile-time features (architecture/build-features.md D4). The default
/// config we write lists exactly these, and a `providers` entry outside this
/// set can never be mounted — it earns a startup warning
/// ([`ServerSettings::unavailable_providers`]).
pub const BUILT_IN_PROVIDERS: &[&str] = &[
#[cfg(feature = "tidal")]
"tidal",
#[cfg(feature = "youtube")]
"youtube",
#[cfg(feature = "fyyd")]
"fyyd",
#[cfg(feature = "abs")]
"abs",
#[cfg(feature = "soundcloud")]
"soundcloud",
#[cfg(feature = "jamendo")]
"jamendo",
#[cfg(feature = "fs")]
"fs",
#[cfg(feature = "fs")]
"crabidy",
#[cfg(feature = "fs")]
"orphans",
];
/// Contents of `crabidy-server.toml`. /// Contents of `crabidy-server.toml`.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ServerSettings { pub struct ServerSettings {
/// Enabled providers. The default config we write lists all of them
/// ([`ALL_PROVIDERS`]); remove a name to disable that provider. Absent —
/// the key deleted, or a fresh install with no file — enables all of them,
/// so a server never silently loses every provider (fail-open for the
/// library, unlike auth).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<Vec<String>>,
/// Role credentials; absent (or empty) means the server runs open. /// Role credentials; absent (or empty) means the server runs open.
#[serde(default)] #[serde(default, skip_serializing_if = "AuthSettings::is_default")]
pub auth: AuthSettings, pub auth: AuthSettings,
/// Audio output selection; absent means the system default device.
#[serde(default, skip_serializing_if = "AudioSettings::is_default")]
pub audio: AudioSettings,
}
/// Audio output configuration.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AudioSettings {
/// A substring of the output device's name (case-insensitive) to send
/// audio to. Absent uses the system default device — which on a Raspberry
/// Pi is often HDMI, so audio plays but is silent on the jack/DAC. List
/// the available names with `crabidy-server audio-devices`, then set e.g.
/// `device = "Headphones"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
}
impl AudioSettings {
/// Whether this is the empty default (no device pinned), so the
/// auto-written config omits the `[audio]` table entirely.
fn is_default(&self) -> bool {
self.device.is_none()
}
}
/// Which providers to mount, resolved from [`ServerSettings::providers`].
#[derive(Debug, Clone, Copy)]
pub struct ProviderToggles {
pub tidal: bool,
pub youtube: bool,
pub fyyd: bool,
pub abs: bool,
pub soundcloud: bool,
pub jamendo: bool,
pub fs: bool,
pub crabidy: bool,
pub orphans: bool,
}
impl ProviderToggles {
/// Every provider this binary has on — the default, and what a
/// missing/keyless config yields. Providers built out are off: a feature
/// that is not compiled in cannot be toggled on
/// (architecture/build-features.md D4).
pub fn all() -> Self {
Self {
tidal: cfg!(feature = "tidal"),
youtube: cfg!(feature = "youtube"),
fyyd: cfg!(feature = "fyyd"),
abs: cfg!(feature = "abs"),
soundcloud: cfg!(feature = "soundcloud"),
jamendo: cfg!(feature = "jamendo"),
fs: cfg!(feature = "fs"),
// Both live on the content store, which the `fs` feature brings
// (D5).
crabidy: cfg!(feature = "fs"),
orphans: cfg!(feature = "fs"),
}
}
} }
/// One PHC password hash per role; a role without a hash cannot /// One PHC password hash per role; a role without a hash cannot
/// authenticate. Generate hashes with `crabidy-server hash-password`. /// authenticate. Generate hashes with `crabidy-server guard <role>`.
/// Hashes are not passwords, but the file should stay private anyway. /// Hashes are not passwords, but the file should stay private anyway.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthSettings { pub struct AuthSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>, pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_owner: Option<String>, pub queue_owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_appender: Option<String>, pub queue_appender: Option<String>,
} }
@ -40,6 +157,41 @@ impl AuthSettings {
pub fn enabled(&self) -> bool { pub fn enabled(&self) -> bool {
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some() self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
} }
/// Whether this is the empty default (no roles) — so the auto-written
/// default config omits the `[auth]` table entirely.
fn is_default(&self) -> bool {
self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none()
}
/// Enforces the guarding order: roles must be locked from the most
/// privileged down. Anonymous callers inherit the highest *unguarded*
/// role (architecture/roles-auth.md), so guarding a lower role while a
/// higher one is open is meaningless — the anonymous role would still
/// outrank it. Such a config is a mistake, not a subtle preference, so
/// it aborts startup rather than running with a surprising posture.
///
/// Valid guarded sets are prefixes of `[owner, queue_owner,
/// queue_appender]`: nothing, `owner`, `owner`+`queue_owner`, or all
/// three. `queue_owner` without `owner`, or `queue_appender` without
/// `queue_owner`, is rejected.
pub fn validate(&self) -> Result<(), String> {
if self.queue_owner.is_some() && self.owner.is_none() {
return Err(
"[auth] queue_owner is guarded but owner is not; guard roles from most to \
least privileged (owner, then queue_owner, then queue_appender)"
.to_string(),
);
}
if self.queue_appender.is_some() && self.queue_owner.is_none() {
return Err(
"[auth] queue_appender is guarded but queue_owner is not; guard roles from most \
to least privileged (owner, then queue_owner, then queue_appender)"
.to_string(),
);
}
Ok(())
}
} }
impl ServerSettings { impl ServerSettings {
@ -57,7 +209,97 @@ impl ServerSettings {
} }
Err(err) => return Err(format!("cannot read {}: {err}", file.display())), Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
}; };
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) let settings: ServerSettings =
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))?;
// A broken guarding order is fail-closed like any other parse error:
// abort startup rather than run with a surprising auth posture.
settings
.auth
.validate()
.map_err(|err| format!("invalid {}: {err}", file.display()))?;
Ok(settings)
}
/// Whether the named provider is enabled: every *built-in* provider when
/// the `providers` key is absent, otherwise only the names the list holds.
///
/// A provider this binary was not built with is never enabled, whatever
/// the config says (architecture/build-features.md D4) — the compile-time
/// set is the outer bound of the runtime one.
pub fn provider_enabled(&self, name: &str) -> bool {
BUILT_IN_PROVIDERS.contains(&name)
&& self
.providers
.as_ref()
.is_none_or(|list| list.iter().any(|p| p == name))
}
/// Names the `providers` list asks for that this binary cannot mount:
/// providers built out (`tidal` in a build without the `tidal` feature)
/// and outright unknown names (a typo). The caller logs one warning per
/// name at startup — the library layer is fail-open, so this never aborts
/// (architecture/build-features.md D4).
pub fn unavailable_providers(&self) -> Vec<&str> {
self.providers
.as_deref()
.unwrap_or_default()
.iter()
.map(String::as_str)
.filter(|name| !BUILT_IN_PROVIDERS.contains(name))
.collect()
}
/// The per-provider mount decisions for the orchestrator. `orphans` also
/// requires `crabidy` (it is a view over the store); that dependency is
/// enforced where the store is built, so it is not folded in here.
pub fn provider_toggles(&self) -> ProviderToggles {
ProviderToggles {
tidal: self.provider_enabled("tidal"),
youtube: self.provider_enabled("youtube"),
fyyd: self.provider_enabled("fyyd"),
abs: self.provider_enabled("abs"),
soundcloud: self.provider_enabled("soundcloud"),
jamendo: self.provider_enabled("jamendo"),
fs: self.provider_enabled("fs"),
crabidy: self.provider_enabled("crabidy"),
orphans: self.provider_enabled("orphans"),
}
}
/// Writes a default `crabidy-server.toml` — every provider this binary has
/// ([`BUILT_IN_PROVIDERS`]), no auth — when none exists yet, so users have
/// a full list to prune. A file that already exists (even a pruned one) is
/// left untouched. Best-effort: the caller treats a write failure as a
/// warning, not a startup error.
pub fn ensure_default(config_dir: &Path) -> Result<(), String> {
let file = config_dir.join(SETTINGS_FILE);
match std::fs::metadata(&file) {
Ok(_) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(format!("cannot check {}: {err}", file.display())),
}
let settings = ServerSettings {
providers: Some(BUILT_IN_PROVIDERS.iter().map(|s| s.to_string()).collect()),
auth: AuthSettings::default(),
audio: AudioSettings::default(),
};
settings.store(config_dir)
}
/// Serializes the current settings back to `crabidy-server.toml` in
/// `config_dir`, creating the directory if missing.
///
/// The flat `[auth]` shape is preserved (each role a top-level key), so a
/// reload under `#[serde(deny_unknown_fields)]` still parses. Roles left
/// `None` are simply omitted — they cannot authenticate, exactly as a
/// missing key. The password never appears here (only the PHC hash).
pub fn store(&self, config_dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(config_dir)
.map_err(|err| format!("cannot create {}: {err}", config_dir.display()))?;
let text = toml::to_string_pretty(self)
.map_err(|err| format!("cannot serialize server settings: {err}"))?;
let file = config_dir.join(SETTINGS_FILE);
std::fs::write(&file, text).map_err(|err| format!("cannot write {}: {err}", file.display()))
} }
} }
@ -78,16 +320,261 @@ mod tests {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");
std::fs::write( std::fs::write(
dir.path().join(SETTINGS_FILE), dir.path().join(SETTINGS_FILE),
"[auth]\nqueue_appender = \"$argon2id$fake\"\n", "[auth]\nowner = \"$argon2id$fake\"\n",
) )
.expect("write"); .expect("write");
let settings = ServerSettings::load(dir.path()).expect("parse"); let settings = ServerSettings::load(dir.path()).expect("parse");
assert!(settings.auth.enabled()); assert!(settings.auth.enabled());
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$fake"));
assert!(settings.auth.queue_owner.is_none());
assert!(settings.auth.queue_appender.is_none());
}
#[test]
fn validate_accepts_top_down_guarded_prefixes() {
// Every valid guarded set is a prefix of owner → queue_owner →
// queue_appender.
let owner = "$argon2id$o".to_string();
let qo = "$argon2id$qo".to_string();
let qa = "$argon2id$qa".to_string();
let cases = [
AuthSettings::default(),
AuthSettings {
owner: Some(owner.clone()),
queue_owner: None,
queue_appender: None,
},
AuthSettings {
owner: Some(owner.clone()),
queue_owner: Some(qo.clone()),
queue_appender: None,
},
AuthSettings {
owner: Some(owner),
queue_owner: Some(qo),
queue_appender: Some(qa),
},
];
for case in cases {
assert!(case.validate().is_ok(), "{case:?}");
}
}
#[test]
fn validate_rejects_a_role_guarded_below_an_open_one() {
// queue_owner without owner.
let broken = AuthSettings {
owner: None,
queue_owner: Some("$argon2id$qo".to_string()),
queue_appender: None,
};
assert!(broken.validate().is_err());
// queue_appender without queue_owner.
let broken = AuthSettings {
owner: Some("$argon2id$o".to_string()),
queue_owner: None,
queue_appender: Some("$argon2id$qa".to_string()),
};
assert!(broken.validate().is_err());
}
#[test]
fn a_broken_guarding_order_aborts_load() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
)
.expect("write");
let err = ServerSettings::load(dir.path()).expect_err("must reject");
assert!(err.contains("crabidy-server.toml"), "{err}");
assert!(err.contains("queue_appender"), "{err}");
}
#[test]
fn store_sets_one_role_and_preserves_the_others() {
let dir = TempDir::new().expect("tempdir");
// Seed a file with two roles already set.
std::fs::write(
dir.path().join(SETTINGS_FILE),
"[auth]\nowner = \"$argon2id$owner\"\nqueue_owner = \"$argon2id$qo\"\n",
)
.expect("seed");
let mut settings = ServerSettings::load(dir.path()).expect("load");
// Set the third role and write it back.
settings.auth.queue_appender = Some("$argon2id$appender".to_string());
settings.store(dir.path()).expect("store");
// Reload: all three roles present, still parses under deny_unknown.
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$owner"));
assert_eq!(reloaded.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
assert_eq!( assert_eq!(
settings.auth.queue_appender.as_deref(), reloaded.auth.queue_appender.as_deref(),
Some("$argon2id$fake") Some("$argon2id$appender")
);
}
#[test]
fn store_creates_the_config_dir_when_missing() {
let dir = TempDir::new().expect("tempdir");
let nested = dir.path().join("config").join("crabidy");
let mut settings = ServerSettings::default();
settings.auth.owner = Some("$argon2id$x".to_string());
settings.store(&nested).expect("store creates dir");
assert!(nested.join(SETTINGS_FILE).is_file());
}
#[test]
fn an_absent_providers_key_enables_every_built_in_provider() {
let settings = ServerSettings::default();
for provider in BUILT_IN_PROVIDERS {
assert!(settings.provider_enabled(provider), "{provider}");
}
// …and nothing this binary was built without.
for provider in ALL_PROVIDERS {
if !BUILT_IN_PROVIDERS.contains(&provider) {
assert!(!settings.provider_enabled(provider), "{provider}");
}
}
}
/// A provider the binary lacks cannot be turned on from the config: the
/// compile-time set bounds the runtime one
/// (architecture/build-features.md D4).
#[test]
#[cfg(not(feature = "tidal"))]
fn a_built_out_provider_cannot_be_enabled() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"tidal\"]\n").expect("write");
let settings = ServerSettings::load(dir.path()).expect("load");
assert!(!settings.provider_enabled("tidal"));
assert!(!settings.provider_toggles().tidal);
assert_eq!(settings.unavailable_providers(), vec!["tidal"]);
}
#[test]
fn unavailable_providers_reports_built_out_and_unknown_names() {
// An unknown name (a typo) is always unavailable, whatever the build.
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"providers = [\"fs\", \"tidl\", \"\"]\n",
)
.expect("write");
let settings = ServerSettings::load(dir.path()).expect("load");
let unavailable = settings.unavailable_providers();
assert!(unavailable.contains(&"tidl"), "{unavailable:?}");
assert!(unavailable.contains(&""), "{unavailable:?}");
// `fs` is only available when it was built in.
assert_eq!(
unavailable.contains(&"fs"),
!cfg!(feature = "fs"),
"{unavailable:?}"
);
// An absent key asks for nothing, so nothing is unavailable.
assert!(ServerSettings::default().unavailable_providers().is_empty());
}
#[test]
fn ensure_default_lists_only_built_in_providers() {
let dir = TempDir::new().expect("tempdir");
ServerSettings::ensure_default(dir.path()).expect("write default");
let reloaded = ServerSettings::load(dir.path()).expect("reload");
let listed = reloaded.providers.expect("providers key");
assert_eq!(listed, BUILT_IN_PROVIDERS);
// Nothing it writes can then warn at startup.
assert!(reloaded_unavailable(dir.path()).is_empty());
}
fn reloaded_unavailable(dir: &Path) -> Vec<String> {
ServerSettings::load(dir)
.expect("reload")
.unavailable_providers()
.into_iter()
.map(str::to_string)
.collect()
}
// Asserts on `fs`/`crabidy`, so it needs a build that has them.
#[cfg(feature = "fs")]
#[test]
fn an_explicit_list_enables_only_its_names() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"providers = [\"fs\", \"crabidy\"]\n",
)
.expect("write");
let settings = ServerSettings::load(dir.path()).expect("load");
assert!(settings.provider_enabled("fs") && settings.provider_enabled("crabidy"));
assert!(!settings.provider_enabled("tidal"));
assert!(!settings.provider_enabled("youtube"));
assert!(!settings.provider_enabled("orphans"));
let toggles = settings.provider_toggles();
assert!(toggles.fs && toggles.crabidy);
assert!(!toggles.tidal && !toggles.youtube && !toggles.orphans);
}
#[test]
fn ensure_default_writes_all_providers_and_never_clobbers() {
let dir = TempDir::new().expect("tempdir");
ServerSettings::ensure_default(dir.path()).expect("write default");
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
for provider in BUILT_IN_PROVIDERS {
assert!(text.contains(provider), "default lists {provider}");
}
assert!(
!text.contains("[auth]"),
"no empty auth table in the default"
);
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(
reloaded.providers.as_ref().map(Vec::len),
Some(BUILT_IN_PROVIDERS.len())
);
// A second call must not overwrite a file the user has since pruned.
std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune");
ServerSettings::ensure_default(dir.path()).expect("no-op");
let after = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
assert_eq!(after.trim(), "providers = [\"fs\"]");
}
#[test]
fn providers_and_auth_round_trip() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"providers = [\"tidal\", \"fs\"]\n[auth]\nowner = \"$argon2id$x\"\n",
)
.expect("seed");
let settings = ServerSettings::load(dir.path()).expect("load");
assert!(settings.auth.enabled());
settings.store(dir.path()).expect("store");
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(
reloaded.providers,
Some(vec!["tidal".to_string(), "fs".to_string()])
);
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x"));
}
#[test]
fn audio_device_round_trips_and_omits_when_unset() {
let dir = TempDir::new().expect("tempdir");
// Unset: the [audio] table is omitted entirely.
ServerSettings::default().store(dir.path()).expect("store");
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
assert!(!text.contains("[audio]"), "no empty audio table: {text}");
// Set: it round-trips.
let mut settings = ServerSettings::default();
settings.audio.device = Some("plughw:CARD=sndrpihifiberry".to_string());
settings.store(dir.path()).expect("store");
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(
reloaded.audio.device.as_deref(),
Some("plughw:CARD=sndrpihifiberry")
); );
assert!(settings.auth.owner.is_none());
} }
#[test] #[test]

View File

@ -23,7 +23,7 @@ fn service() -> RpcService {
let (update_tx, _) = tokio::sync::broadcast::channel(4); let (update_tx, _) = tokio::sync::broadcast::channel(4);
let (playback_tx, _playback_rx) = flume::unbounded(); let (playback_tx, _playback_rx) = flume::unbounded();
let (provider_tx, _provider_rx) = flume::unbounded(); let (provider_tx, _provider_rx) = flume::unbounded();
RpcService::new(update_tx, playback_tx, provider_tx) RpcService::new(update_tx, playback_tx, provider_tx, false)
} }
fn hash(password: &str) -> String { fn hash(password: &str) -> String {
@ -88,13 +88,14 @@ async fn unknown_get_paths_fall_back_to_the_shell() {
#[tokio::test] #[tokio::test]
async fn grpc_web_calls_route_through_the_auth_layer() { async fn grpc_web_calls_route_through_the_auth_layer() {
// A credentialed server: an unauthenticated gRPC-web POST must be // A fully-locked server (every role guarded, so anonymous callers get
// rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before* // nothing): an unauthenticated gRPC-web POST must be rejected by the
// reaching a handler — so the dead channels never matter. // layer (gRPC status UNAUTHENTICATED = 16) *before* reaching a handler
// — so the dead channels never matter.
let auth = Authenticator::new(&AuthSettings { let auth = Authenticator::new(&AuthSettings {
owner: Some(hash("pw")), owner: Some(hash("pw")),
queue_owner: None, queue_owner: Some(hash("qo")),
queue_appender: None, queue_appender: Some(hash("qa")),
}); });
let router = crabidy_server::build_router(service(), Arc::new(auth)); let router = crabidy_server::build_router(service(), Arc::new(auth));
let response = router let response = router

38
devenv-docs.nix Normal file
View File

@ -0,0 +1,38 @@
{ pkgs, ... }:
let
mdbook-admonish = pkgs.mdbook-admonish.overrideAttrs (old: rec {
version = "1.20.0-unstable-pr235";
src = pkgs.fetchFromGitHub {
owner = "tommilligan";
repo = "mdbook-admonish";
rev = "f67dc47c24bc48dada3ae4decf055fdd6ba4a4ed";
hash = "sha256-vD71Hhjn43NnqUg+sluybC2wejjNsY6cvj/PsPvIUnw=";
};
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src;
name = "mdbook-admonish-${version}-vendor";
hash = "sha256-FQo58eT9SyO5bhuoRQOAfBcAi1acBOPjYH6WUtiJPIE=";
};
});
in
{
packages = with pkgs; [
mdbook
mdbook-admonish
mdbook-d2
mdbook-footnote
mdbook-toc
d2
];
scripts.docs.exec = ''
export D2_NO_XML_TAG="''${D2_NO_XML_TAG:-true}"
mdbook serve docs --hostname "''${MDBOOK_HOST:-127.0.0.1}" --port "''${MDBOOK_PORT:-3000}" "$@"
'';
scripts.docs-build.exec = ''
export D2_NO_XML_TAG="''${D2_NO_XML_TAG:-true}"
mdbook build docs "$@"
'';
}

View File

@ -26,6 +26,14 @@
vulkan-tools vulkan-tools
]; ];
env.RUSTC_WRAPPER = "sccache"; env.RUSTC_WRAPPER = "sccache";
# Per-user sccache server socket. On the default shared TCP port (4226)
# the first user's server performs every rustc write for every user, so a
# second user's builds die with EACCES in their own target/ dir.
enterShell = ''
sccache_dir="''${XDG_RUNTIME_DIR:-/tmp/sccache-$(id -u)}"
mkdir -p "$sccache_dir"
export SCCACHE_SERVER_UDS="$sccache_dir/sccache.sock"
'';
# https://devenv.sh/languages/ # https://devenv.sh/languages/
languages.rust = { languages.rust = {
enable = true; enable = true;

View File

@ -15,6 +15,13 @@ let
pkg-config pkg-config
protobuf protobuf
cargo-cross cargo-cross
# opusic-sys (via symphonia-adapter-libopus) compiles libopus from source
# with CMake, using the gnumake generator from stdenv. Do NOT add ninja:
# its mere presence flips cmake's generator (Make -> Ninja), which then
# conflicts with any build dir already cached under the other generator
# ("Does not match the generator used previously"). Keeping only cmake
# makes the generator deterministic.
cmake
# Stream-URL sidecar for the ytdy provider: YouTube caps tokenless # Stream-URL sidecar for the ytdy provider: YouTube caps tokenless
# stream URLs at ~1 MiB and yt-dlp is the only maintained cipher # stream URLs at ~1 MiB and yt-dlp is the only maintained cipher
# solver (architecture/youtube-rustypipe.md, D2-revised). # solver (architecture/youtube-rustypipe.md, D2-revised).
@ -28,7 +35,10 @@ let
]; ];
in in
{ {
imports = [ ./devenv-rust.nix ]; imports = [
./devenv-rust.nix
./devenv-docs.nix
];
# The wasm target for cbd-web; merges with the languages.rust # The wasm target for cbd-web; merges with the languages.rust
# settings in devenv-rust.nix. # settings in devenv-rust.nix.
@ -64,6 +74,60 @@ in
scripts.serve-web.exec = '' scripts.serve-web.exec = ''
cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@" cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@"
''; '';
# Generates the CLI shell completions and man pages into dist/ by building
# the native binaries with CBD_ASSET_DIR set (architecture/cli.md D7). Each
# binary's build.rs copies its assets into $CBD_ASSET_DIR when present, so
# dist/completions/** and dist/man/*.1 appear after this runs. Unlike
# build-web (a wasm build) this is a native build, so RUSTFLAGS is left
# alone — the mold linker flag is correct for the native toolchain.
scripts.gen-cli-assets.exec = ''
cd "$DEVENV_ROOT" && CBD_ASSET_DIR="$DEVENV_ROOT/dist" cargo build "$@"
'';
# The build-feature matrix (architecture/build-features.md D11,
# quality/build-features.md G6/G7). Feature combinations are where cfg rot
# hides: a curated set catches what matters without a powerset sweep. Every
# entry must be clippy-clean under -D warnings; the two extremes (defaults
# and nothing) also run their tests.
scripts.check-features.exec = ''
set -euo pipefail
cd "$DEVENV_ROOT"
clippy() {
echo "==> clippy $*"
cargo clippy --all-targets "$@" -- -D warnings
}
test_it() {
echo "==> test $*"
cargo test "$@"
}
# The two extremes, tests included.
clippy -p crabidy-server
test_it -p crabidy-server
clippy -p crabidy-server --no-default-features
test_it -p crabidy-server --no-default-features
# Each provider on its own: nothing else may be needed to compile it.
for feature in tidal youtube fyyd abs soundcloud jamendo fs; do
clippy -p crabidy-server --no-default-features --features "$feature"
done
# Each non-provider axis dropped from an otherwise full build.
clippy -p crabidy-server --no-default-features --features all-providers,spectrum,web-ui
clippy -p crabidy-server --no-default-features --features all-providers,opus,web-ui
clippy -p crabidy-server --no-default-features --features all-providers,opus,spectrum
# The local-files appliance and the streaming box from the docs.
clippy -p crabidy-server --no-default-features --features fs,opus
clippy -p crabidy-server --no-default-features --features tidal,web-ui,opus,spectrum
# The other feature-carrying crates.
clippy -p audio-player --no-default-features
clippy -p cbd-tui --no-default-features
test_it -p cbd-tui --no-default-features
clippy -p cbd --no-default-features
clippy -p cbd --no-default-features --features fs,opus,notifications
echo "all feature combinations are clean"
'';
enterShell = ""; enterShell = "";

37
docs/book.toml Normal file
View File

@ -0,0 +1,37 @@
[book]
authors = ["Hans Mündelein"]
language = "en"
src = "src"
title = "crabidy"
[preprocessor]
[preprocessor.footnote]
before = ["admonish"]
markdown = true
[preprocessor.admonish]
command = "mdbook-admonish"
assets_version = "3.1.0" # do not edit: managed by `mdbook-admonish install`
[preprocessor.d2]
path = "d2"
# layout engine for diagrams. See https://github.com/terrastruct/d2#plugins.
# optional. default is "dagre".
layout = "dagre"
# whether to use inline svg when rendering.
# Keep this true for `mdbook serve`: embedded SVG output writes generated files
# under src/<output-dir>, which mdBook watches and rebuilds in a loop.
# optional. default is 'true'
inline = true
# output directory relative to `src/` for generated diagrams.
# This is ignored if 'inline' is 'true'.
# optional. default is "d2".
output-dir = "d2"
[preprocessor.toc]
command = "mdbook-toc"
renderer = ["html"]
[output.html]
additional-css = ["./mdbook-admonish.css"]

356
docs/mdbook-admonish.css Normal file
View File

@ -0,0 +1,356 @@
@charset "UTF-8";
:is(.admonition) {
display: flow-root;
margin: 1.5625em 0;
padding: 0 1.2rem;
color: var(--fg);
page-break-inside: avoid;
background-color: var(--bg);
border: 0 solid black;
border-inline-start-width: 0.4rem;
border-radius: 0.2rem;
box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.05), 0 0 0.1rem rgba(0, 0, 0, 0.1);
}
@media print {
:is(.admonition) {
box-shadow: none;
}
}
:is(.admonition) > * {
box-sizing: border-box;
}
:is(.admonition) :is(.admonition) {
margin-top: 1em;
margin-bottom: 1em;
}
:is(.admonition) > .tabbed-set:only-child {
margin-top: 0;
}
html :is(.admonition) > :last-child {
margin-bottom: 1.2rem;
}
a.admonition-anchor-link {
display: none;
position: absolute;
left: -1.2rem;
padding-right: 1rem;
}
a.admonition-anchor-link:link, a.admonition-anchor-link:visited {
color: var(--fg);
}
a.admonition-anchor-link:link:hover, a.admonition-anchor-link:visited:hover {
text-decoration: none;
}
a.admonition-anchor-link::before {
content: "§";
}
:is(.admonition-title, summary.admonition-title) {
position: relative;
min-height: 4rem;
margin-block: 0;
margin-inline: -1.6rem -1.2rem;
padding-block: 0.8rem;
padding-inline: 4.4rem 1.2rem;
font-weight: 700;
background-color: rgba(68, 138, 255, 0.1);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
display: flex;
}
:is(.admonition-title, summary.admonition-title) p {
margin: 0;
}
html :is(.admonition-title, summary.admonition-title):last-child {
margin-bottom: 0;
}
:is(.admonition-title, summary.admonition-title)::before {
position: absolute;
top: 0.625em;
inset-inline-start: 1.6rem;
width: 2rem;
height: 2rem;
background-color: #448aff;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
mask-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>');
-webkit-mask-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>');
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
content: "";
}
:is(.admonition-title, summary.admonition-title):hover a.admonition-anchor-link {
display: initial;
}
@media print {
details.admonition::details-content {
display: contents;
}
}
details.admonition > summary.admonition-title::after {
position: absolute;
top: 0.625em;
inset-inline-end: 1.6rem;
height: 2rem;
width: 2rem;
background-color: currentcolor;
mask-image: var(--md-details-icon);
-webkit-mask-image: var(--md-details-icon);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
content: "";
transform: rotate(0deg);
transition: transform 0.25s;
}
details[open].admonition > summary.admonition-title::after {
transform: rotate(90deg);
}
summary.admonition-title::-webkit-details-marker {
display: none;
}
:root {
--md-details-icon: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42Z'/></svg>");
}
:root {
--md-admonition-icon--admonish-note: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z'/></svg>");
--md-admonition-icon--admonish-abstract: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M17 9H7V7h10m0 6H7v-2h10m-3 6H7v-2h7M12 3a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z'/></svg>");
--md-admonition-icon--admonish-info: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2z'/></svg>");
--md-admonition-icon--admonish-tip: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M17.66 11.2c-.23-.3-.51-.56-.77-.82-.67-.6-1.43-1.03-2.07-1.66C13.33 7.26 13 4.85 13.95 3c-.95.23-1.78.75-2.49 1.32-2.59 2.08-3.61 5.75-2.39 8.9.04.1.08.2.08.33 0 .22-.15.42-.35.5-.23.1-.47.04-.66-.12a.58.58 0 0 1-.14-.17c-1.13-1.43-1.31-3.48-.55-5.12C5.78 10 4.87 12.3 5 14.47c.06.5.12 1 .29 1.5.14.6.41 1.2.71 1.73 1.08 1.73 2.95 2.97 4.96 3.22 2.14.27 4.43-.12 6.07-1.6 1.83-1.66 2.47-4.32 1.53-6.6l-.13-.26c-.21-.46-.77-1.26-.77-1.26m-3.16 6.3c-.28.24-.74.5-1.1.6-1.12.4-2.24-.16-2.9-.82 1.19-.28 1.9-1.16 2.11-2.05.17-.8-.15-1.46-.28-2.23-.12-.74-.1-1.37.17-2.06.19.38.39.76.63 1.06.77 1 1.98 1.44 2.24 2.8.04.14.06.28.06.43.03.82-.33 1.72-.93 2.27z'/></svg>");
--md-admonition-icon--admonish-success: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='m9 20.42-6.21-6.21 2.83-2.83L9 14.77l9.88-9.89 2.83 2.83L9 20.42z'/></svg>");
--md-admonition-icon--admonish-question: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='m15.07 11.25-.9.92C13.45 12.89 13 13.5 13 15h-2v-.5c0-1.11.45-2.11 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41a2 2 0 0 0-2-2 2 2 0 0 0-2 2H8a4 4 0 0 1 4-4 4 4 0 0 1 4 4 3.2 3.2 0 0 1-.93 2.25M13 19h-2v-2h2M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10c0-5.53-4.5-10-10-10z'/></svg>");
--md-admonition-icon--admonish-warning: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2 1 21z'/></svg>");
--md-admonition-icon--admonish-failure: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M20 6.91 17.09 4 12 9.09 6.91 4 4 6.91 9.09 12 4 17.09 6.91 20 12 14.91 17.09 20 20 17.09 14.91 12 20 6.91z'/></svg>");
--md-admonition-icon--admonish-danger: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M11 15H6l7-14v8h5l-7 14v-8z'/></svg>");
--md-admonition-icon--admonish-bug: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M14 12h-4v-2h4m0 6h-4v-2h4m6-6h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6.002 6.002 0 0 0-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8z'/></svg>");
--md-admonition-icon--admonish-example: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 13v-2h14v2H7m0 6v-2h14v2H7M7 7V5h14v2H7M3 8V5H2V4h2v4H3m-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17H2m2.25-7a.75.75 0 0 1 .75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1h2.25z'/></svg>");
--md-admonition-icon--admonish-quote: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M14 17h3l2-4V7h-6v6h3M6 17h3l2-4V7H5v6h3l-2 4z'/></svg>");
}
:is(.admonition):is(.admonish-note) {
border-color: #448aff;
}
:is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(68, 138, 255, 0.1);
}
:is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #448aff;
mask-image: var(--md-admonition-icon--admonish-note);
-webkit-mask-image: var(--md-admonition-icon--admonish-note);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
border-color: #00b0ff;
}
:is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(0, 176, 255, 0.1);
}
:is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #00b0ff;
mask-image: var(--md-admonition-icon--admonish-abstract);
-webkit-mask-image: var(--md-admonition-icon--admonish-abstract);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-info, .admonish-todo) {
border-color: #00b8d4;
}
:is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(0, 184, 212, 0.1);
}
:is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #00b8d4;
mask-image: var(--md-admonition-icon--admonish-info);
-webkit-mask-image: var(--md-admonition-icon--admonish-info);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-tip, .admonish-hint, .admonish-important) {
border-color: #00bfa5;
}
:is(.admonish-tip, .admonish-hint, .admonish-important) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(0, 191, 165, 0.1);
}
:is(.admonish-tip, .admonish-hint, .admonish-important) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #00bfa5;
mask-image: var(--md-admonition-icon--admonish-tip);
-webkit-mask-image: var(--md-admonition-icon--admonish-tip);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-success, .admonish-check, .admonish-done) {
border-color: #00c853;
}
:is(.admonish-success, .admonish-check, .admonish-done) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(0, 200, 83, 0.1);
}
:is(.admonish-success, .admonish-check, .admonish-done) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #00c853;
mask-image: var(--md-admonition-icon--admonish-success);
-webkit-mask-image: var(--md-admonition-icon--admonish-success);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-question, .admonish-help, .admonish-faq) {
border-color: #64dd17;
}
:is(.admonish-question, .admonish-help, .admonish-faq) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(100, 221, 23, 0.1);
}
:is(.admonish-question, .admonish-help, .admonish-faq) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #64dd17;
mask-image: var(--md-admonition-icon--admonish-question);
-webkit-mask-image: var(--md-admonition-icon--admonish-question);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-warning, .admonish-caution, .admonish-attention) {
border-color: #ff9100;
}
:is(.admonish-warning, .admonish-caution, .admonish-attention) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(255, 145, 0, 0.1);
}
:is(.admonish-warning, .admonish-caution, .admonish-attention) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ff9100;
mask-image: var(--md-admonition-icon--admonish-warning);
-webkit-mask-image: var(--md-admonition-icon--admonish-warning);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-failure, .admonish-fail, .admonish-missing) {
border-color: #ff5252;
}
:is(.admonish-failure, .admonish-fail, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(255, 82, 82, 0.1);
}
:is(.admonish-failure, .admonish-fail, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ff5252;
mask-image: var(--md-admonition-icon--admonish-failure);
-webkit-mask-image: var(--md-admonition-icon--admonish-failure);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-danger, .admonish-error) {
border-color: #ff1744;
}
:is(.admonish-danger, .admonish-error) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(255, 23, 68, 0.1);
}
:is(.admonish-danger, .admonish-error) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ff1744;
mask-image: var(--md-admonition-icon--admonish-danger);
-webkit-mask-image: var(--md-admonition-icon--admonish-danger);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-bug) {
border-color: #f50057;
}
:is(.admonish-bug) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(245, 0, 87, 0.1);
}
:is(.admonish-bug) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f50057;
mask-image: var(--md-admonition-icon--admonish-bug);
-webkit-mask-image: var(--md-admonition-icon--admonish-bug);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-example) {
border-color: #7c4dff;
}
:is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(124, 77, 255, 0.1);
}
:is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #7c4dff;
mask-image: var(--md-admonition-icon--admonish-example);
-webkit-mask-image: var(--md-admonition-icon--admonish-example);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
:is(.admonition):is(.admonish-quote, .admonish-cite) {
border-color: #9e9e9e;
}
:is(.admonish-quote, .admonish-cite) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(158, 158, 158, 0.1);
}
:is(.admonish-quote, .admonish-cite) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #9e9e9e;
mask-image: var(--md-admonition-icon--admonish-quote);
-webkit-mask-image: var(--md-admonition-icon--admonish-quote);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
}
.navy :is(.admonition) {
background-color: var(--sidebar-bg);
}
.ayu :is(.admonition),
.coal :is(.admonition) {
background-color: var(--theme-hover);
}
.rust :is(.admonition) {
background-color: var(--sidebar-bg);
color: var(--sidebar-fg);
}
.rust .admonition-anchor-link:link, .rust .admonition-anchor-link:visited {
color: var(--sidebar-fg);
}

24
docs/src/SUMMARY.md Normal file
View File

@ -0,0 +1,24 @@
# Summary
- [Introduction](./intro.md)
- [Architecture](./architecture.md)
- [The library model](./library.md)
- [Providers](./providers.md)
- [Filesystem — /fs](./providers/fs.md)
- [Tidal — /tidal](./providers/tidal.md)
- [YouTube — /youtube](./providers/youtube.md)
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
- [Jamendo — /jamendo](./providers/jamendo.md)
- [audiobookshelf — /abs](./providers/abs.md)
- [fyyd — /fyyd](./providers/fyyd.md)
- [Search](./providers/search.md)
- [The crabidy store](./store.md)
- [Queue and playback](./queue.md)
- [Clients](./clients.md)
- [Terminal UI — cbd-tui](./clients/tui.md)
- [Web client — cbd-web](./clients/web.md)
- [The cbd bundle](./clients/cbd.md)
- [Command line](./clients/cli.md)
- [Configuration](./config.md)
- [Tailored builds](./build-features.md)
- [Roles and authorization](./auth.md)

239
docs/src/architecture.md Normal file
View File

@ -0,0 +1,239 @@
# Architecture
<!-- toc -->
The server is a single process that owns the music library, the play
queue, and audio output. Clients are thin: they send commands and redraw
from a stream of updates the server pushes at them. This page describes
what happens inside the server and how one "play this" request travels
from a client all the way to the sound device.
For the pieces around it, see [The library model](./library.md),
[Providers](./providers.md), [The crabidy store](./store.md),
[Queue and playback](./queue.md), and [Clients](./clients.md).
## One gRPC service
The server exposes exactly one gRPC service, `CrabidyService`. Almost
every RPC is a plain request/response call — browse the library
(`GetLibraryNode`), change the queue (`Replace`, `Append`, `Insert`,
`Remove`, `SetCurrent`, `ClearQueue`), control playback (`TogglePlay`,
`Next`, `Prev`, `Stop`, `ChangeVolume`, `ToggleMute`, `RestartTrack`),
and edit the library (`CreateLibraryNode`, `RenameLibraryNode`,
`DeleteLibraryNode`, `CaptureLibraryNode`).
One RPC is different: `GetUpdateStream` is server-streaming. A client
subscribes once and the server pushes a `GetUpdateStreamResponse` every
time shared state changes. Each response is a `oneof` carrying one of:
- `Queue` — the whole queue, plus a `resolving` flag that is true while
the server is still turning queued paths into tracks.
- `QueueModifiers` — shuffle and repeat.
- `QueueTrack` — the current track (with album) and its queue position.
- `PlayState` — stopped, loading, playing, or paused.
- `TrackPosition` — elapsed and total seconds of the current track.
- `CaptureProgress` — how far a running capture has got.
- `SpectrumFrame` — one frame of the audio spectrum for the visualizer.
- volume and mute changes.
```admonish note
Nothing is polled. A client fetches an initial snapshot with `Init`,
subscribes to `GetUpdateStream`, and from then on its view of the queue
and playback is redrawn purely from pushed updates. Several clients can
subscribe at once and they all see the same state live.
```
The update stream is deliberately lossy: it is a bounded broadcast
channel, and a subscriber that falls too far behind receives an explicit
`data_loss` status and must resubscribe rather than silently missing
updates.
## Inside the process
Within the server there are three concurrency domains connected by
channels, not shared locks:
- the **tonic gRPC layer** — the RPC handlers and the update stream;
- the **playback loop** — a tokio task that owns the queue and play
state and is the single writer of both;
- the **ProviderOrchestrator loop** — a tokio task that routes every
library and track call to the provider that owns the path.
Below the playback loop sits the **audio player**: a dedicated OS thread
running the decoder and the audio sink, because that work is synchronous
and CPU-bound and would starve the async runtime. Commands flow to it
over a bounded channel; player events (state changes, position ticks,
end-of-stream) flow back and are turned into playback commands.
```d2
direction: down
grpc: tonic gRPC layer {
handlers: RPC handlers
stream: GetUpdateStream
}
playback: Playback loop {
explanation: |md
tokio task
owns the queue
owns play state
|
}
orch: ProviderOrchestrator loop {
explanation: |md
tokio task
routes by path prefix
|
}
engine: Audio player {
explanation: |md
OS thread
decoder + sink
|
}
providers: Providers {
tidal
youtube
fs
crabidy
}
device: Audio device
grpc.handlers -> playback: PlaybackMessage (bounded)
grpc.handlers -> orch: ProviderMessage (bounded)
playback -> orch: ResolveTracks / GetTrackUrls (reply)
orch -> providers: routed by first path segment
playback -> engine: play / pause / stop
engine -> playback: state, position, end-of-stream
engine -> device: PCM
playback -> grpc.stream: updates (broadcast)
```
```admonish tip
Every message crossing a channel carries the sender's tracing span, and
the consumer instruments its handler as a child of that span. Without
this a command's log events would attach to whichever request happened
to be running on the consumer task.
```
### The playback loop is the single writer
The queue and the play state live behind mutexes inside the playback
loop, but only the loop's own handlers ever touch them, and no lock is
held across an `await`. There is one writer of playback state, so the
queue a client sees is always internally consistent. Every queue change
runs through one broadcast helper, which is what guarantees the
`resolving` flag is never forgotten and that each change also reaches the
queue-persistence task.
### The ProviderOrchestrator is the library root
The orchestrator is the root of the library tree. It serves the
synthetic root `/` itself — a node whose children are the mounted
providers — and routes everything else by the **first path segment**.
It holds one **mount** per provider: the library root it owns (`/tidal`,
`/fs`, …), the name shown in the root listing, and the client itself behind a
trait object. Every routed call — `get_lib_node`, `resolve_tracks_into`,
`get_urls_for_track`, `get_metadata_for_track`, and the
create/rename/delete/capture calls — looks up the one mount that owns the
path and forwards to it. Ownership stops at a path separator, so `/fsx` is
not `/fs`. A path no mount owns is rejected as malformed (or, for editing
calls, unsupported) rather than guessed at. The mounts are sorted once, so
the root always lists `crabidy` first, `orphans` last, and the rest
alphabetically.
Mounting is the only place a provider is named, which is what makes a
provider droppable in three independent ways: not compiled in (see [Tailored
builds](./build-features.md)), not enabled in `crabidy-server.toml`, or
failed to initialize. A failure to initialize logs a warning and omits that
child; the server runs without it. Tidal is the one exception — a Tidal
config that exists but cannot be loaded aborts startup. Because the
orchestrator sits behind an `Arc`, a slow library walk is spawned onto its
own task while the loop keeps serving other commands.
```admonish note
Routing is by the *resolved* track path. When a track's playable is a
link into another provider, the owning provider rewrites the track's
path to the target before it is queued, so `GetTrackUrls` for that track
routes straight to the real provider. See [The library
model](./library.md) for how links resolve.
```
## The audio player
The player is not a tokio task — it is an OS thread wrapping a `rodio`
sink and a decoder. It wakes on a command or, failing that, on a short
timeout, on which it emits an elapsed-position tick. End-of-stream is
detected by a callback appended after the decoder that carries a
generation number: it fires only when the current track drains
naturally, and a stale end-of-stream from a track that was already
replaced is recognised by its generation and dropped. When it does fire,
it becomes a playback command that advances the queue.
The player accepts a source string. An `http(s)` URL is streamed;
anything else is opened as a local file path. That is why every provider,
however it stores its media, ultimately hands playback a URL or a plain
file path.
## How "play this" flows
Suppose a client selects a Tidal album and asks to replace the queue with
it. The path names a node, not a single track, so the server flattens it
into tracks, starts playing the first one, and streams the growing queue
back as it goes.
```d2
shape: sequence_diagram
client: Client
grpc: gRPC handler
pb: Playback loop
orch: ProviderOrchestrator
tidal: Tidal provider
player: Audio player
client -> grpc: "Replace([/tidal/.../album])"
grpc -> pb: "PlaybackMessage(Replace)"
pb -> orch: "ResolveTracks(path, chunk channel)"
orch -> tidal: "resolve subtree (spawned)"
tidal -> pb: "chunk of Tracks" {style.bold: true}
pb -> pb: "append to queue, first track becomes current"
pb -> client: "Queue update (resolving = true)"
pb -> orch: "GetTrackUrls(current track path)"
orch -> tidal: get stream urls
tidal -> pb: "[stream url]"
pb -> player: "play(url)"
player -> pb: "PlayState = playing, position ticks"
pb -> client: "QueueTrack + PlayState + TrackPosition"
tidal -> pb: "more chunks..." {style.bold: true}
pb -> client: Queue updates
pb -> client: "final Queue update (resolving = false)"
player -> device: PCM
```
The important properties:
- The RPC handler returns as soon as the command is on the playback
loop's channel; it does not wait for resolution or playback.
- Resolution streams in **chunks**. The queue grows as chunks arrive, and
each growth is broadcast, so a client sees a big album or playlist fill
in progressively with `resolving = true` until the final update clears
the flag.
- The moment a chunk makes a track current, the playback loop resolves
that one track's stream URLs and hands them to the player. Browsing a
slow, paginated library never blocks the audio starting.
- If a track's URLs fail to resolve, or it was recorded as skipped, the
loop advances past it — one full pass at most — rather than stalling.
- Everything the client shows afterwards (queue contents, current track,
play state, position) arrives as pushed updates on the stream it is
already subscribed to.
A "play this single track" request is the *same* RPC with a track path
instead of a node path: it resolves to exactly one track. Queueing a
playlist and queueing a track differ only in the shape of the path.

122
docs/src/auth.md Normal file
View File

@ -0,0 +1,122 @@
# Roles and authorization
<!-- toc -->
By default the server is **open**: it listens on the network and anyone
who can reach the port has full control — not only playback, but library
writes such as renaming or deleting your saves. This is the right
default on a trusted home network with no config to write.
Adding password hashes to the `[auth]` section of `crabidy-server.toml`
locks the server down **from the top**. Each password you set lowers
what a caller with **no credentials** may do; a caller *with* a matching
password is elevated to that role. You lock the powerful roles first and
leave the weaker ones open for anyone on your network — see
[What anonymous callers get](#what-anonymous-callers-get) below.
## The three roles
Roles are ordered by privilege; each includes the rights of the ones
below it (owner ⊃ queue-owner ⊃ queue-appender):
- **owner** — the normal user: everything, including all library
writes.
- **queue-owner** — anything on the queue and playback (append, remove,
reorder, clear, shuffle, repeat, play/stop, next/prev, volume, mute,
…), but **no library writes**: no bookmarks (`w`), no captures (`W`),
no saving, renaming, or deleting.
- **queue-appender** — may browse and search the library and **append**
tracks to the queue; nothing else. (Searching is allowed because
appending something first means finding it.)
## Turning it on
Each role is credentialed by one argon2id password hash in PHC format.
Generate a hash and store it in the config in one step with:
```sh
crabidy-server guard <role>
```
where `<role>` is `owner`, `queue-owner`, or `queue-appender`. This
reads a password, hashes it (argon2id), and writes the resulting PHC
string into `crabidy-server.toml`'s `[auth]` table. The file ends up
like this:
```toml
[auth]
# One PHC hash per role. Guard from the top down.
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..."
```
A role's PHC hash — not its password — is what lives in the file. Omit a
role's key to leave that role **open**: no password is needed to act as
it, and no one can log in as it.
## What anonymous callers get
A request with no credentials is not rejected — it is granted the
**most powerful role you did *not* guard**. Setting a password walks
that floor down one step:
| Guarded roles | A no-credential caller is… |
| --- | --- |
| *(none — no `[auth]`)* | **owner** — the open default |
| `owner` | **queue-owner** |
| `owner` + `queue_owner` | **queue-appender** |
| all three | *nothing* — credentials required |
So to run a server where guests may append but a password is needed to
run the queue, guard `owner` **and** `queue_owner` and leave
`queue_appender` open.
Because the anonymous caller always gets the highest open role, guarding
a weaker role while a stronger one is still open would be pointless — the
anonymous role would simply outrank it. Such a config is treated as a
mistake: setting `queue_owner` without `owner`, or `queue_appender`
without `queue_owner`, **aborts startup**, and `crabidy-server guard`
refuses to write it. Guard the roles from the top down.
## How enforcement behaves
- **Fail-closed.** Authorization is enforced in one place, in front of
the RPC handlers, and it is default-deny: an unknown or future method
requires the owner role until it is explicitly mapped to a lower one.
No handler ever sees an unauthorized request.
- **Startup is strict.** A `crabidy-server.toml` that exists but does
not parse — or that guards the roles out of order — aborts server
startup rather than silently running open: a broken auth config never
downgrades to a weaker posture than intended.
- **Clients send the role as the username.** A client authenticates by
sending the role name (`owner`, `queue-owner`, `queue-appender`) as
the basic-auth user and the role's password as the basic-auth
password. Set these with the client `auth` subcommand or in the
client config's `[server]` table (see [Configuration](./config.md)).
With no credentials configured the client sends no header and is
treated as the anonymous role — which keeps the zero-config local
setup working against an open server.
- A caller below a method's required role — whether anonymous or logged
in — gets gRPC `PERMISSION_DENIED`. A wrong password (and an anonymous
request to a fully-locked server) gets `UNAUTHENTICATED`, with every
authentication failure answering identically so a caller cannot probe
which part was wrong. A wrong password is never quietly downgraded to
the anonymous role.
```admonish warning
The transport is plain HTTP/2 — basic auth travels in the clear. This is
fine on a trusted LAN. Put TLS in front of anything exposed beyond it (a
reverse proxy or a VPN); crabidy does not terminate TLS itself.
```
Secrets — client passwords, the `authorization` header, and stream
tokens — are never written to logs, traces, or error messages.
## See also
- [Introduction](./intro.md) — the trust model in one paragraph.
- [Configuration](./config.md) — the client `[server]` table and file
locations.
- [Command line](./clients/cli.md) — the `guard` and `auth`
subcommands.

135
docs/src/build-features.md Normal file
View File

@ -0,0 +1,135 @@
# Tailored builds
<!-- toc -->
Every provider, Opus decoding, the spectrum bars, the embedded web UI and
the TUI's desktop notifications sit behind a Cargo **feature**. All of them
are **on by default**, so a plain `cargo build` gives you the full player.
Turning some off gives you a smaller binary that pulls fewer dependencies —
useful for a single-purpose box (a Raspberry Pi playing a local flac
collection) or a build environment you want to keep lean.
This is the *compile-time* half of provider selection. The runtime half —
the `providers` list in `crabidy-server.toml` — still works exactly as
before; see [Configuration](./config.md#enabling-and-disabling-providers).
The two compose in one direction: **a provider that is not compiled in
cannot be enabled in the config**, and if you name one anyway the server
logs a warning and carries on.
## What this build has
```sh
crabidy-server features # or: cbd features
```
prints one feature per line — the providers it can mount, then the extras.
The same list goes into the server's startup log, so a support question
("why is `/tidal` missing?") is answerable from the binary and its log.
## The features
| Feature | Turning it off drops |
| ------------ | ----------------------------------------------------------- |
| `tidal` | the `/tidal` provider (`tidaldy`) |
| `youtube` | the `/youtube` provider (`ytdy`, and with it `rustypipe`) |
| `fyyd` | the `/fyyd` podcast provider |
| `abs` | the `/abs` audiobookshelf provider |
| `soundcloud` | the `/soundcloud` provider |
| `jamendo` | the `/jamendo` provider |
| `fs` | local files **and persistent state** — see below |
| `opus` | Ogg-Opus decoding (`symphonia` + a bundled libopus C build) |
| `spectrum` | the server-side FFT feeding clients' spectrum bars |
| `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) |
Plus two conveniences: `all-providers` enables the seven provider features
at once, and `cbd` (the bundle) mirrors every feature above and adds
`notifications` for the TUI's desktop "now playing" popups (`notify-rust`,
which on Linux pulls a D-Bus stack).
### `fs` is more than `/fs`
The `/fs` provider, the content store, and everything built on it are all
written against the same crate, so they share one feature. With `fs` off you
lose:
- the `/fs` mount (your local music folder);
- `/crabidy` — saved queues, bookmarks (`w`) and captures (`W`);
- `/orphans`, which is a view over the store;
- **queue persistence**: the queue lives in memory, so a restart starts
empty;
- the `scan` command (it fails with a message naming the feature);
- captured-track markers in library listings.
The capture and save-queue RPCs answer `Unimplemented` on such a server, so
clients report an ordinary error instead of hanging.
If you want `/fs` but not `/crabidy`, keep the feature and prune the
`providers` list at runtime instead — that is what it is for.
### `opus` and the libopus build
`symphonia` (and so rodio) has no Opus decoder, so Ogg-Opus files are
decoded through a bundled libopus, which needs `cmake` at build time.
Turning `opus` off removes both the crates and that build requirement.
The feature also decides whether `scan` treats `.opus` files as playable at
all, so a build that cannot decode Opus will not index Opus files either. An
Opus file that reaches such a build fails to decode with a message naming
the missing feature and is skipped, exactly like any unplayable file.
### `spectrum` and `web-ui`
`spectrum` only affects the *server*: with it off no frames are computed or
broadcast, and clients simply show no bars — nothing else changes, and no
client needs rebuilding. `web-ui` drops the embedded browser client; the
gRPC service on port 50051 still serves `cbd-tui` and the CLI.
## Examples
A local-files appliance — no network providers, no web UI, no FFT:
```sh
cargo build --release -p crabidy-server \
--no-default-features --features fs,opus
```
A streaming box with the browser client and the bars, no local library:
```sh
cargo build --release -p crabidy-server \
--no-default-features --features tidal,web-ui,opus,spectrum
```
The bundle, tailored the same way (its features forward to the server):
```sh
cargo build --release -p cbd --no-default-features --features fs,opus
```
A terminal client with no D-Bus dependency:
```sh
cargo build --release -p cbd-tui --no-default-features
```
`--no-default-features` on its own is legal and compiles: you get a server
that starts, serves an empty library and plays nothing. It is the base case
the feature matrix checks, not a useful deployment.
## What is *not* behind a feature
- **Authorization.** `[auth]` and its password hashing always ship. A build
that ignored configured role hashes would silently run an
intended-to-be-locked server open — a fail-open hole not worth a small
dependency.
- **Audio output.** The server is the player; a server without audio has no
purpose.
- **HLS streaming and the spectrum tap.** They bring no dependencies of
their own, so gating them would add build complexity and save nothing.
## Checking combinations
`devenv shell -- check-features` runs the curated matrix — defaults, no
features, each provider alone, each extra dropped, both examples above, and
the client crates — and requires every one to be clippy-clean. Run it after
changing anything that sits behind a feature.

47
docs/src/clients.md Normal file
View File

@ -0,0 +1,47 @@
# Clients
A client is anything that drives the server. Every client speaks the
same gRPC service (see [Architecture](./architecture.md)): it sends
**commands** (browse the library, change the queue, control playback)
and subscribes to the **update stream** that pushes the current queue,
play state, track position, capture progress, and the frequency
spectrum as they change. Nothing is polled — each client redraws from
the pushed updates, so several clients driving one server always agree
on what is playing.
Because the contract is one shared service, the clients are the same
program wearing different skins. They differ only in how you interact
with them, not in what they can do.
```d2
direction: right
server: crabidy-server {
shape: rectangle
}
tui: cbd-tui (terminal)
web: cbd-web (browser)
cbd: cbd (server + TUI)
cli: CLI subcommands
tui -> server: gRPC
web -> server: gRPC-web
cli -> server: gRPC (one-shot)
cbd -> server: localhost gRPC
```
- [Terminal UI — cbd-tui](./clients/tui.md) — the ratatui/crossterm
terminal client, driven by vim-style keys.
- [Web client — cbd-web](./clients/web.md) — a Leptos/WASM browser
client with the same functionality, served by the server itself.
- [The cbd bundle](./clients/cbd.md) — server and terminal client in
one process for the single-machine case.
- [Command line](./clients/cli.md) — every binary is also a clap CLI;
the `library`/`queue`/`global` subcommands are a scriptable remote
control.
All clients authenticate the same way, using the role name as the
basic-auth user (see [Roles and authorization](./auth.md)). Connection
defaults live in each client's own config file (see
[Configuration](./config.md)).

51
docs/src/clients/cbd.md Normal file
View File

@ -0,0 +1,51 @@
# The cbd bundle
`cbd` is the server and the terminal client in one process, for the
single-machine case where you just want to play music without running a
separate server. Starting `cbd` starts an in-process
[server](../architecture.md) and then runs the [terminal UI](./tui.md)
against it; everything else — the config format, the gRPC wire, every
feature — is exactly the same as running the two halves separately.
## What it does at start
```d2
direction: right
cbd: "cbd (one process)" {
boot: "main: spawn server,\nwait, run TUI"
srv: "in-process server\n(0.0.0.0:50051)"
tui: "cbd-tui"
boot -> srv: "spawn (port taken → adopt)"
boot -> tui: after readiness
tui -> srv: localhost gRPC
}
```
1. It spawns the server on a background task. If the port is already
taken because a standalone `crabidy-server` is already running, `cbd`
logs that and carries on — the TUI simply connects to the existing
server instead of failing.
2. It waits until the configured server address accepts a connection
(bounded retries, then a clear error), so the TUI never starts
against a server that is not ready yet.
3. It runs the TUI exactly as `cbd-tui` would.
The bundled TUI talks to the in-process server over localhost gRPC, the
same way a remote TUI would — there is no in-process shortcut. Quitting
the TUI ends the process, and with it the in-process server; the queue
is persisted continuously (see [Queue and playback](../queue.md)), so
the next start restores it.
## Its own config
`cbd` reads its own `cbd.toml`, **not** `cbd-tui.toml`. The two files
have the same options but are separate so the self-contained `cbd`
(defaulting to its localhost server) and a `cbd-tui` pointed at a remote
server can coexist on one machine without one dragging the other's
`address`. See [Configuration](../config.md).
`cbd` also carries the CLI subcommands of both halves — the server's
`guard`/`scan`, the client's `auth`, and the shared
`library`/`queue`/`global` remote commands. See [Command
line](./cli.md).

136
docs/src/clients/cli.md Normal file
View File

@ -0,0 +1,136 @@
# Command line
<!-- toc -->
Every binary is a clap CLI. Run any of them with `--help` (and any
subcommand with `--help`) for the full surface. Running a binary with
**no subcommand** behaves as it always has: `crabidy-server` runs the
server, `cbd-tui` runs the [TUI](./tui.md), and `cbd` runs the
[in-process server + TUI](./cbd.md).
The subcommands fall into three groups: the shared remote commands
(available on every binary), the server-only commands, and the
client-only command.
## Remote commands: `library` / `queue` / `global`
`library`, `queue`, and `global` are available on all three binaries
and act as a scriptable remote control: each one connects to a running
server over gRPC, sends a single command, prints the result, and exits.
A `crabidy-server` running these is just acting as a client to whatever
server is up — including its own.
Connection flags go **before** the subcommand and fall back to the
[client config](../config.md) file (`cbd-tui.toml` / `cbd.toml`) when
omitted:
- `--address <URL>` — the server to connect to.
- `--user <role>` / `--password <pw>` — basic-auth credentials, using
the role name as the user (see [Roles and
authorization](../auth.md)); leave empty against an open server.
```sh
crabidy-server library list /tidal # browse a node
cbd-tui --address http://pi:50051 queue append /fs/album
cbd global play # toggle play/pause
cbd global volume -- -0.1 # lower the volume
```
- `library list [PATH]` browses a [library node](../library.md)
(default `/`), printing its child nodes and tracks; captured rows are
marked. `library create`/`rename`/`delete`, and `library save`/
`capture` (the `w`/`W` equivalents into `/crabidy` — see [The crabidy
store](../store.md)) mutate it.
- `queue show` prints the [queue](../queue.md); `queue append`/`insert`/
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
<NAME>`, `queue shuffle`, and `queue repeat` change it.
- `global play`/`stop`/`next`/`prev`/`restart`/`mute` and `global
volume <DELTA>` control playback.
## Server commands
These live on `crabidy-server` (and `cbd`). They act on the server's
own config and content store, not over the wire.
`guard <role> [password] [--no-config]` hashes a role password with
argon2id and prints the PHC string to stdout. Unless `--no-config` is
given, it also writes the hash into the role's field in
`crabidy-server.toml`'s `[auth]`, preserving the other roles. Roles are
`owner`, `queue-owner`, and `queue-appender` (see [Roles and
authorization](../auth.md)). With the password omitted it reads stdin.
```sh
printf '%s' 'my-password' | crabidy-server guard owner
crabidy-server guard queue-owner 'pw' --no-config # just print the PHC
```
`scan <path> [--capture|--move]` walks a folder recursively and drops a
`.cbd-track.toml` beside every audio file, so the folder browses as a
tree under [`/fs`](../providers/fs.md). Existing `.cbd-track.toml` files
are left untouched. By default the sidecar points at the audio in place.
`--capture` copies each file into the content store instead (the sidecar
points there, de-duplicated); `--move` moves it into the store, leaving
only the sidecar behind. See [The crabidy store](../store.md).
```sh
crabidy-server scan ~/Music # index in place
crabidy-server scan ~/Downloads --capture # copy audio into the store
```
`audio-devices [device]` lists the audio output devices the server can see,
marking the one the current config selects. Given a name (or a fragment of
one) it writes `[audio] device` into `crabidy-server.toml` and then lists, so
one command both configures and confirms — see [The audio output
device](../config.md#the-audio-output-device).
```sh
crabidy-server audio-devices # list
crabidy-server audio-devices Headphones # pin one, then list
```
`features` prints the build features this binary was compiled with — the
providers it can mount, then the extras (`opus`, `spectrum`, `web-ui`) — one
per line. It answers "why is `/tidal` missing?" without guesswork; see
[Tailored builds](../build-features.md).
```console
$ crabidy-server features
fs
crabidy
orphans
opus
```
## Client command: `auth`
`auth <role> [password] [--address ADDR]` lives on `cbd-tui` (and
`cbd`). It writes the role name as the `user`, the cleartext password,
and optionally the address into the [client config](../config.md), so
you do not have to edit the file by hand.
```sh
cbd-tui auth owner 'my-password'
cbd-tui auth queue-owner 'pw' --address http://pi:50051
```
```admonish warning
A password given as a command-line **argument** is visible in the
process list (e.g. `ps`) for as long as the command runs. Prefer
omitting it: `guard` then reads the password from stdin, which keeps it
out of argv and is pipe-friendly. The client config written by `auth`
stores the password in plaintext, so keep that file private.
```
## Completions and man pages
```sh
cbd-tui completions bash # print a completion script
devenv shell -- gen-cli-assets # write dist/completions + dist/man
```
Each binary can print a shell completion script (bash/zsh/fish) to
stdout with the hidden `completions <shell>` subcommand. `gen-cli-assets`
builds the binaries with `CBD_ASSET_DIR=$PWD/dist`, producing
`dist/completions/**` and `dist/man/*.1` for all three binaries. Every
ordinary build also emits these into the crate's `OUT_DIR`.

164
docs/src/clients/tui.md Normal file
View File

@ -0,0 +1,164 @@
# Terminal UI — cbd-tui
<!-- toc -->
`cbd-tui` is the terminal client: a ratatui/crossterm application that
connects to a running server over gRPC and redraws from the pushed
update stream. It reads `cbd-tui.toml` for its connection defaults (see
[Configuration](../config.md)); flags before a subcommand override the
file, and running it with no subcommand starts the TUI (see
[Command line](./cli.md)).
## Layout
The screen has two focusable panes side by side and a now-playing pane:
- **Library** (left) — the current [library node](../library.md): the
child folders and tracks under the path you have browsed to. Entering
a folder replaces the listing.
- **Queue** (right) — the play [queue](../queue.md), with the playing
track highlighted.
- **Now playing** — the current track, a progress gauge, and the
frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum),
fills the rest of the right column).
`Tab` cycles focus between the library and the queue; keys are routed to
whichever pane has focus (plus the global keys, which apply in either).
## Navigating
Navigation is vim-style. In the library, `j`/`k` move the selection,
`l` enters the selected folder, `h` goes back to the parent, and
`g`/`G` and `Ctrl-d`/`Ctrl-u` jump. `Enter` **replaces** the queue with
the current selection; `a` appends it and `L` queues it after the
current track. In the queue, `Enter` plays the selected track, `o`
jumps the selection to the playing track, `d` removes a track, `p`
inserts the library selection after the selected track, and `c`/`C`
clear the queue.
`%` creates a child of the open node where that is allowed (e.g. a
search term under `/tidal/search` — see [Search](../providers/search.md));
`e` renames such a node; `d` deletes the selection. Playback keys
(`Space`, `r`, `Ctrl-n`/`Ctrl-p`, volume, `m`, `z`, `x`) are global.
Press `?` at any time for the full binding table; `Esc` or `?` closes
it, and while it is open every other key is inert.
## Marks and saving
- `s` toggles a **mark** on the selected item. Marks live on the full
item list (they survive filtering) so a marked-but-hidden row still
counts when you queue or save.
- `v` (or `V`) enters **visual mode** in the library: movement then marks
or unmarks the rows you sweep over, like vim's visual selection. The
selection is anchored where you entered it, so moving back over a row
reverses it. `Esc` leaves visual mode (keeping the marks and any `/`
filter), and so does any non-movement action. The pane title shows
`— VISUAL` while it is on.
- `w` **saves** the selection — a library subtree, or in the queue pane
the whole queue — as a new folder of link files under
`/crabidy/<name>`; playback replays it from the source provider.
- `W` **captures** the selection into `/crabidy/<name>`: like `w`, but
each track's audio is fetched into the shared content store
(de-duplicated), so it plays back fully local afterwards. It works on
a library subtree and on the queue. Downloads can take a while;
progress shows in the library pane. See [The crabidy
store](../store.md).
- Captured rows are marked with a trailing `↓` (down-arrow) at the end of
the row, visible even while you browse another provider, so you can see
what you already have.
```admonish note
Saves and captures all live under the one `/crabidy` provider. Inside
`/crabidy`, `d` deletes a folder or track immediately with no
confirmation — it removes only the metadata toml, never the shared
store audio, which other saves may reference.
```
## The `/` live filter
Pressing `/` opens a search input that filters the focused pane's items
live as you type (case-insensitive substring). It **narrows** the
visible list rather than jumping to a match, so you can filter and then
act on what is left. `Enter` keeps the filter applied and returns to
navigation; `Esc` clears it. Movement, marks, and queue actions all map
back to the real underlying rows, so acting on a filtered row affects
the right track.
```admonish tip
This `/` filter is a local view filter over items already listed. It is
distinct from the search-node feature (`%` on `/tidal/search` and
friends), which asks a provider for results. See
[Search](../providers/search.md).
```
Entering a library folder resets the filter (a fresh listing); the
queue's filter is preserved across the constant queue updates and only
its visible set is recomputed.
## Frequency spectrum
A row of frequency bars is drawn under the track progress while audio
plays, as block glyphs (`▁▂▃▄▅▆▇█`) in the accent color. The bars are
produced on the **server**: it taps its own audio output, runs an FFT
(~20 fps), folds the result into a few log-spaced bins, and streams
them on the update stream like every other bit of live state. So the
bars work whether the server is local or on another machine, and when
nothing is playing they fall to the floor.
Press `f` to show or hide them at runtime; `spectrum` in the client
config sets the startup default (`spectrum = true` is the default;
`false` starts hidden). Either way it is only a display choice — the
server always computes and streams the bars while audio flows.
## Key bindings
Global keys work in either pane. Pane keys apply only while that pane is
focused (several chords, like `j`/`Enter`/`d`, mean different things per
pane).
| Scope | Key | Action |
| ------- | ----------------- | ----------------------------------------- |
| Global | `?` | Show help |
| Global | `q` | Quit |
| Global | `Tab` | Switch between library and queue |
| Global | `Space` | Play/pause |
| Global | `r` | Restart current track |
| Global | `K` | Volume up |
| Global | `J` | Volume down |
| Global | `m` | Toggle mute |
| Global | `z` | Toggle shuffle |
| Global | `x` | Toggle repeat |
| Global | `Ctrl-n` | Next track |
| Global | `Ctrl-p` | Previous track |
| Global | `f` | Toggle the frequency spectrum |
| Library | `j` / `k` | Select next / previous item |
| Library | `g` / `G` | Select first / last item |
| Library | `Ctrl-d` | Jump 15 items down |
| Library | `Ctrl-u` | Jump 15 items up |
| Library | `h` | Go to parent folder |
| Library | `l` | Enter selected folder |
| Library | `s` | Mark / unmark selection |
| Library | `v` / `V` | Visual mode: movement toggles marks |
| Library | `Enter` | Replace queue with selection |
| Library | `a` | Append selection to queue |
| Library | `L` | Queue selection after current track |
| Library | `%` | Create node here (e.g. search term) |
| Library | `e` | Rename selected node |
| Library | `d` | Delete selection |
| Library | `w` | Save selection as bookmark |
| Library | `W` | Capture selection into /crabidy (audio) |
| Library | `/` | Filter this view |
| Queue | `j` / `k` | Select next / previous track |
| Queue | `g` / `G` | Select first / last track |
| Queue | `Ctrl-d` | Jump 15 tracks down |
| Queue | `Ctrl-u` | Jump 15 tracks up |
| Queue | `o` | Select the playing track |
| Queue | `Enter` | Play selected track |
| Queue | `p` | Insert library selection after this track |
| Queue | `d` | Remove selected track |
| Queue | `c` | Clear queue except current track |
| Queue | `C` | Clear entire queue |
| Queue | `w` | Save queue under a name |
| Queue | `W` | Capture the queue into /crabidy (audio) |
| Queue | `/` | Filter this view |
| Help | `?` / `Esc` / `q` | Close help |

73
docs/src/clients/web.md Normal file
View File

@ -0,0 +1,73 @@
# Web client — cbd-web
`cbd-web` is a browser client with the same functionality as the
[terminal UI](./tui.md): library browsing, search terms, queue
manipulation, playback control, saves (`w`), captures (`W`, with
progress), and the live update stream. It is a Leptos application
compiled to WebAssembly and rendered client-side in the browser — no
terminal required, so phones, tablets, and guests can drive the server.
Every TUI binding has a clickable equivalent, and the familiar keyboard
bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and
queue keys, `?` for help) also work on desktop browsers. The `/` live
filter is TUI-only for now.
## How it is served
The browser talks **gRPC-web** to the same service the TUI uses. There
is no second API surface: the server wraps its existing gRPC service in
a gRPC-web layer, so the browser calls the same
`/crabidy.v1.CrabidyService/…` paths, including the streaming
`GetUpdateStream`, from the same generated clients.
```d2
direction: right
browser: Browser {
app: cbd-web (Leptos CSR wasm)
}
server: "crabidy-server :50051" {
static: embedded cbd-web bundle
grpcweb: gRPC-web layer
rpc: CrabidyService
grpcweb -> rpc
}
browser.app -> server.static: GET / and assets
browser.app -> server.grpcweb: gRPC-web (fetch)
```
Everything is served on the server's own address — gRPC for the TUI,
gRPC-web for the browser, and the static app assets all share one port.
Opening `http://<server>:50051/` in a browser is the whole install
story; there is no separate host to run.
## Embedded in the server
`cbd-web` is not run directly. It is built to a WASM bundle and embedded
into `crabidy-server` behind the `web-ui` cargo feature, which is **on
by default** (and thus present in `cbd` too). A plain `cargo build`
needs no WASM toolchain: until you build the bundle, the server embeds a
"web UI not built" placeholder page. Building the bundle re-embeds it
automatically on the next server build.
```sh
devenv shell -- build-web # writes the cbd-web bundle
cargo build -p crabidy-server # embeds it
```
Building the server with `--no-default-features` drops the feature for a
headless, gRPC-only binary.
## Auth and theme
The static app shell is public; every RPC behind it stays gated. When
the server has [auth](../auth.md) configured, the client shows a login
form on the first unauthorized RPC and sends the role name and password
as basic-auth credentials — the same `[auth]` roles the TUI honors,
enforced identically. Credentials and the theme choice are kept in the
browser.
The interface offers a light and a dark theme with a manual toggle,
following the browser's preference by default.

185
docs/src/config.md Normal file
View File

@ -0,0 +1,185 @@
# Configuration
<!-- toc -->
Crabidy reads its configuration from TOML files in `~/.config/crabidy/`,
the platform config directory. Every file is optional. On first start
each component writes its own file filled in with defaults, then reads
it back — so a fresh install runs with sensible values and leaves you an
editable file for each piece. A provider whose config fails to load
simply does not mount; it does not stop the server.
Which providers mount at all is controlled by `crabidy-server.toml` (see
[Enabling and disabling providers](#enabling-and-disabling-providers)),
which the server now also writes on first start.
## The config files
| File | Component | Credentials needed |
| --------------------- | ---------------- | --------------------------- |
| `abs.toml` | audiobookshelf | server URL + API key |
| `fsdy.toml` | local files | none |
| `fyyd.toml` | podcasts | none |
| `jamendo.toml` | Jamendo | none (a key is shipped) |
| `soundcloud.toml` | SoundCloud | none (token optional) |
| `tidaly.toml` | Tidal | device login (interactive) |
| `ytdy.toml` | YouTube | none (cookies optional) |
| `cbd-tui.toml` | `cbd-tui` | server role + password |
| `cbd.toml` | `cbd` | server role + password |
| `crabidy-server.toml` | server | its own `[auth]` hashes |
Every file is auto-created with defaults on first start.
- The provider files each get a full option reference — **including how to log
in** — on that provider's page under [Providers](./providers.md).
- `cbd-tui.toml` and `cbd.toml` are client configs (below).
- `crabidy-server.toml` holds the enabled-providers list (below), the audio
output device (below), and server auth (see [Roles and
authorization](./auth.md)).
```admonish warning
Provider credentials and client passwords are stored in **cleartext** — the
files are configuration, not a keyring. Keep `~/.config/crabidy/` private
(`chmod 700` is a reasonable habit). Crabidy redacts secrets from its logs,
error messages, and config dumps.
```
## Enabling and disabling providers
On first start the server writes `crabidy-server.toml` with every provider
enabled:
```toml
providers = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"jamendo",
"fs",
"crabidy",
"orphans",
]
```
**Remove a name to disable that provider** — it no longer mounts and drops
out of the library tree; its own config file (e.g. `tidaly.toml`) is then
left unread. Deleting the whole `providers` line re-enables everything (the
same as a fresh install with no file). Because `orphans` is a view over the
store, disabling `crabidy` disables `orphans` too.
The list can only offer what the binary was **built** with: providers are
also selectable at compile time (see [Tailored
builds](./build-features.md)). The default list written on first start
therefore names only the providers this build has, and if you add one it
does not have, the server logs a warning at startup and ignores it. Run
`crabidy-server features` to see what a binary contains.
## Client config: `cbd-tui.toml` and `cbd.toml`
`cbd-tui` (the standalone terminal client) reads `cbd-tui.toml`; `cbd`
(server plus TUI in one process) reads its own `cbd.toml`. They are
**separate files with the same options** so the two can run side by side
on one machine: a common setup is `cbd` playing locally against its
in-process server while `cbd-tui`, pointed at a remote server (a
Raspberry Pi, say), acts as a remote control. A single shared file would
force one use to follow the other's `address`.
Both live under a `[server]` table:
```toml
[server]
# Where to find the server. Default (both files): localhost, which is
# what cbd's own in-process server listens on. Point cbd-tui.toml at a
# remote server to drive it as a remote control.
address = "http://127.0.0.1:50051"
# Credentials, used only when the server has [auth] configured.
# `user` is the role name (see ./auth.md); leave both empty against an
# open server.
user = ""
password = ""
# Show the frequency-spectrum bars under the track progress.
spectrum = true
```
Every option is also a command-line flag, given before the subcommand
(`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum
false`). A provided flag overrides the file value; an omitted flag
leaves the file value in place. To write credentials into the config
once instead of editing by hand, use the `auth` subcommand (see
[Command line](./clients/cli.md)):
```sh
cbd-tui auth owner 'my-password'
cbd-tui auth queue-owner 'pw' --address http://pi:50051
```
```admonish warning
`password` is stored in **plaintext** — the client always holds a
plaintext credential, not a hash. Keep the client config file private.
The password is never written to logs.
```
For the client's `spectrum` option in context, see
[The terminal client](./clients/tui.md).
## Where the server's own data lives
The server-managed `crabidy` provider does **not** live under
`~/.config`. Following the XDG split, its two parts sit in two roots:
- `~/.local/state/crabidy/` — the provider's TOML tree: the track files
for your saved queues, bookmarks, and captures (the `/crabidy`
library subtree). This is *state*.
- `~/.local/share/crabidy/` — the content-addressed audio store: the
actual playable files that captures download, shared and
de-duplicated across every save. This is *data*.
Neither needs configuration; both start empty and the server manages
them. See [The crabidy store](./store.md) for how saves, captures, and
de-duplication work.
Client and TUI log files also go to `~/.local/state/crabidy/`. The
provider ignores non-track files there, so the logs do not show up in
the library.
```admonish note
A binary built without the `fs` feature has no content store at all: no
`/crabidy`, no bookmarks or captures, and the queue is not persisted across
restarts. See [Tailored builds](./build-features.md).
```
## The audio output device
By default the server plays to the system default output device. On a
Raspberry Pi that is often HDMI, so playback runs but nothing comes out of the
headphone jack or a USB DAC. Ask the server what it can see:
```console
$ crabidy-server audio-devices
Audio output devices (* = selected by the current config):
hdmi:CARD=vc4hdmi,DEV=0
sysdefault:CARD=Headphones
...
```
Then pin one by passing it to the same command, and restart the server:
```console
$ crabidy-server audio-devices Headphones
Set [audio] device = "Headphones" in .../crabidy-server.toml
```
The value is matched case-insensitively as a **substring** of the device
name, so a memorable fragment is enough. It writes:
```toml
[audio]
device = "Headphones"
```
If the fragment matches nothing, both the command and the server warn, and
the server falls back to the system default.

108
docs/src/intro.md Normal file
View File

@ -0,0 +1,108 @@
# Introduction
Crabidy is a client/server music player. A headless server owns the music
library, the play queue, and audio output; thin clients connect to it over
gRPC and drive it. One server can be driven from several clients at once —
a terminal UI on your laptop, a browser tab, a shell script — and they all
see the same queue and playback state live.
This book describes **how the current version works**: the pieces, the
boundaries between them, the behaviour you can rely on, and every
configuration option. It is reference documentation — it describes the system
as it is, not the history of how it got there.
```admonish note
Crabidy is a personal, single-server music player for a trusted home
network. It is not multi-tenant and does not encrypt its transport itself
(see [Roles and authorization](./auth.md)).
```
## The shape of the system
```d2
direction: right
clients: Clients {
tui: cbd-tui (terminal)
web: cbd-web (browser)
cli: cbd / CLI
}
server: crabidy-server {
shape: rectangle
library: Library (providers)
queue: Queue
playback: Playback + audio out
}
media: Media {
streaming: "Tidal · YouTube\nSoundCloud · Jamendo"
spoken: "audiobookshelf · fyyd"
fs: "Local files\n+ the crabidy store"
}
clients -> server: gRPC (commands + update stream)
server.library -> media: fetch / stream
server.playback -> media: stream audio
```
The server exposes one gRPC service. Clients send **commands** (browse the
library, change the queue, control playback) and subscribe to an **update
stream** that pushes the current queue, play state, track position, and
capture progress as they change. Nothing is polled; the UI redraws from
pushed updates.
## The library
Everything you can play lives in one tree, addressed by path. Each top-level
segment is a **provider** mounted as a subtree:
```text
/
├── crabidy your saves: queues, bookmarks, and captures
├── abs audiobookshelf audiobooks
├── fs a local music folder
├── fyyd podcast search
├── jamendo Creative-Commons music
├── soundcloud SoundCloud search, likes, and playlists
├── tidal Tidal streaming
├── youtube YouTube search & playlists
└── orphans store audio no save references any more
```
Which of these you see depends on what your binary was built with, what you
enabled, and which providers came up — see [Providers](./providers.md).
Providers share one node/track model (see [The library
model](./library.md)) so a client browses `/tidal` and `/fs` with the same
keys and the same code. The `crabidy` provider is special: it is where the
server writes the things you save, backed by a content-addressed store of
audio files (see [The crabidy store](./store.md)).
## Binaries
| Binary | What it is |
| ---------------- | ----------------------------------------------------- |
| `crabidy-server` | the server: providers, queue, playback, gRPC |
| `cbd-tui` | the terminal client |
| `cbd-web` | the browser client (WASM), embedded into the server |
| `cbd` | server + terminal client in one process |
Every binary is also a command-line tool (see [Command
line](./clients/cli.md)); running one with no subcommand starts its usual
mode.
## Where to go next
- [Architecture](./architecture.md) — the server's internals and how a
request flows.
- [The library model](./library.md) — nodes, tracks, paths, and links.
- [Providers](./providers.md) — how each media source is wired in.
- [The crabidy store](./store.md) — saves, captures, and de-duplication.
- [Queue and playback](./queue.md) — the queue, playback, and persistence.
- [Clients](./clients.md) — the TUI, the web client, and the CLI.
- [Configuration](./config.md) — every config file and option, including
how to log in to each provider.
- [Tailored builds](./build-features.md) — dropping providers and features
you do not need at compile time.
- [Roles and authorization](./auth.md) — locking a server down.

172
docs/src/library.md Normal file
View File

@ -0,0 +1,172 @@
# The library model
Everything playable in crabidy lives in one tree, addressed by
filesystem-like paths. Every provider — `/tidal`, `/youtube`, `/fs`, and
`/crabidy` — presents that same tree through the same two message types,
so a client browses them all with one piece of code. This page describes
those types, how paths work, and how a saved entry can link to another
provider.
For how the tree is served and routed, see
[Architecture](./architecture.md); for the providers themselves, see
[Providers](./providers.md) and the filesystem provider at
[./providers/fs.md](./providers/fs.md).
## One path-addressed tree
A path is an absolute, `/`-separated string whose first segment names the
provider. The path *is* the position in the tree: the parent of a node is
its path with the last segment trimmed, so no separate parent lookup is
needed. Whether a path is a node or a track is decided by its shape, not
by a type tag — each provider matches the segment slice against the
shapes it knows and rejects anything else as malformed.
```text
/
├── tidal Tidal streaming
├── youtube YouTube search & playlists
├── fs a local music folder
└── crabidy your saves: bookmarks and captures
├── current the live queue, mirrored (reserved)
├── road-trip/ a saved queue (flat)
└── favourite-album/ a saved subtree (structure preserved)
```
Each segment is **percent-encoded**, so arbitrary titles — spaces, `%`,
unicode, a search term someone typed — survive intact as one segment.
Decoding a segment back to `.`/`..` or something containing a separator
is rejected, so a path can only ever descend within its provider.
```admonish note
The synthetic root `/` is owned by the server itself; its children are
exactly the providers that initialized successfully. A provider whose
config or backing store failed to load simply does not appear.
```
## `LibraryNode` and `LibraryNodeChild`
Browsing returns a `LibraryNode`: the node at a path, its `title`, its
child **nodes**, and its **tracks**. A node carries capability flags that
tell a client what it may do here, so the client never hardcodes which
paths support what:
| Field | Meaning |
| ------------------ | -------------------------------------------------- |
| `path`, `title` | this node's address and display name |
| `parent` | the parent path (derivable from `path`) |
| `children` | child nodes, as `LibraryNodeChild` |
| `tracks` | the tracks listed directly under this node |
| `is_queable` | this node can be queued (its subtree flattens) |
| `is_creatable` | children can be created here (`%`) |
| `is_downloadable` | a download capture is allowed; its tracks inherit |
| `tracks_deletable` | the listed tracks may be deleted (`d`) |
| `is_captured` | every track and child below is in the store |
A `LibraryNodeChild` is the lightweight entry for a child node in a
listing — enough to render a row and know what it supports without
fetching it: `path`, `title`, `is_queable`, `is_creatable`,
`is_editable`, `is_deletable`, `is_downloadable`, and `is_captured`.
```admonish note
`is_editable` and `is_deletable` live only on the child entry, because
rename and delete always act on the item *selected in a listing*, never
on the currently-open node. `is_creatable` lives on the open node,
because creation targets the node you are looking at.
```
## `Track`
A track is the unit of playback. Queueing stores whole `Track` messages,
so the queue does not depend on the library still being reachable.
| Field | Meaning |
| ------------------ | -------------------------------------------------- |
| `path` | full library path, provider first — the play key |
| `artist`, `title` | display metadata |
| `duration` | seconds, when known |
| `album` | title and optional release date, when known |
| `is_skipped` | no playable audio; shown red, playback skips it |
| `provider_item_id` | provider-internal id, independent of the path |
| `is_captured` | the content store already holds this track |
`path` is the routing key: playback asks the orchestrator for a track's
stream URLs by its path's first segment. `provider_item_id` is the id
that identifies the item *inside* its provider (a Tidal track id, a
YouTube video id, an fs file path) independent of which path it was
reached by — the same track reached through a playlist and through an
album has two paths but one provider id. The content store keys on it
(see [The crabidy store](./store.md)).
## Links: a saved entry pointing at another provider
A saved entry does not have to hold its own audio — it can be a **link**
to a track path owned by another provider, for example a `/crabidy` or
`/fs` entry pointing at `/tidal/artists/3634161/536243361`. The
providing crate keeps the link target in its own on-disk file; the queue
and playback never see the indirection, because links are resolved when
the entry is listed:
- When a provider builds a `Track` from a link entry, it sets the
track's `path` to the **link target** and fills `artist`/`title`/
`album` from its own file. From that point the track simply *is* a
track of the target provider as far as the queue and playback are
concerned.
- The queued track therefore routes straight to the real provider: no
extra indirection at play time, and a dead target degrades exactly
like any other dead track (a warning, then skip).
```d2
direction: right
fs_entry: "/fs/mix/we-will-rock-you\n(link entry on disk)" {
shape: page
}
resolved: "Track { path = /tidal/.../536243361,\ntitle from the file }" {
shape: rectangle
style.fill: "#e8f4e8"
}
tidal: "/tidal provider\n(resolves stream urls)"
fs_entry -> resolved: listed -> path rewritten to target
resolved -> tidal: queued track routes here
```
```admonish warning
Links resolve **one hop only**, by construction: the path is rewritten
before the track can be queued, so a link whose target is itself a link
is never chained. A target that turns out to be another link dies at
play time with a warning instead — cycles cannot recurse.
```
## Creatable, editable, deletable nodes
Because capabilities travel with every listing, editing gestures are
just RPCs gated on a flag:
- **Create (`%`)** — on a node with `is_creatable`, a typed title
becomes a new child. What that means is provider-defined; under
`/tidal/search` the title is a search term and the created node holds
its results. Creation is idempotent — an existing title returns the
existing node.
- **Edit / rename (`e`)** — on a child with `is_editable`. Because a
search term's title *is* the query, renaming it re-runs the search;
the node's path changes with the title, so the rename returns the
renamed node at its new path.
- **Delete (`d`)** — on a child with `is_deletable`, or a track under a
node with `tracks_deletable`. Delete is idempotent and returns the
refreshed parent, so a client can redraw the listing without a
follow-up fetch. On `/crabidy` a delete removes only the metadata
file or folder and never the shared store audio.
A client renders the available gestures as markers on the row (a `%`
hint on a creatable node, `[e]`/`[d]` suffixes on editable/deletable
children) and silently ignores a keypress whose flag is absent.
## Skipped tracks
A track with `is_skipped` set has no playable audio — a capture recorded
its source as uncapturable but kept the entry so the queue's shape
survives. Clients render it red, and playback skips past it without a
provider round trip. The skip scan is bounded to one pass over the
queue, so a queue of nothing but skipped tracks (even with repeat on)
stops cleanly instead of spinning.

111
docs/src/providers.md Normal file
View File

@ -0,0 +1,111 @@
# Providers
<!-- toc -->
Everything crabidy can play lives in one library tree addressed by path.
The tree is not one monolithic source: it is composed from several
**providers**, each mounted as a subtree under a top-level path prefix.
A single **orchestrator** owns the synthetic root, lists the mounted
providers as its children, and routes every request to a provider by the
first segment of the path.
```d2
direction: right
root: "/ (orchestrator)" {
shape: rectangle
}
crabidy: "/crabidy — your saves & captures"
abs: "/abs — audiobookshelf audiobooks"
fs: "/fs — a local music folder"
fyyd: "/fyyd — podcast search"
jamendo: "/jamendo — Creative-Commons catalogue"
soundcloud: "/soundcloud — SoundCloud"
tidal: "/tidal — Tidal streaming"
youtube: "/youtube — YouTube search & playlists"
orphans: "/orphans — unreferenced store audio"
root -> crabidy: "route /crabidy/*"
root -> abs: "route /abs/*"
root -> fs: "route /fs/*"
root -> fyyd: "route /fyyd/*"
root -> jamendo: "route /jamendo/*"
root -> soundcloud: "route /soundcloud/*"
root -> tidal: "route /tidal/*"
root -> youtube: "route /youtube/*"
root -> orphans: "route /orphans/*"
```
The root lists its children in a fixed order: `crabidy` first, `orphans`
last, and everything else alphabetically in between.
Because routing is purely by prefix, the providers are independent of
one another. They all speak the same node/track model (see [The library
model](./library.md)), so a client browses `/tidal` and `/fs` with the
same keys and the same code, and a track reached through one provider
can be queued alongside a track from another.
## Each provider is optional
A provider appears in the library only if it got that far, and three
independent things decide it:
1. **Was it built in?** Every provider sits behind a Cargo feature (all on by
default). A binary built without one can never mount it — see [Tailored
builds](../build-features.md), and `crabidy-server features` to see what a
binary has.
2. **Is it enabled?** The `providers` list in `crabidy-server.toml` turns
compiled-in providers on and off without a rebuild — see
[Configuration](../config.md#enabling-and-disabling-providers).
3. **Did it initialize?** Only a client that came up successfully is mounted.
```admonish note
Initialization failure is **non-fatal**. A broken config file, missing
credentials, or a missing helper binary drops that one subtree with a
warning — the server and every other provider keep running. The only
exception is Tidal: a Tidal config that exists but cannot be loaded aborts
startup rather than silently dropping your account.
```
So the exact set of top-level entries depends on your build *and* your
configuration: `/tidal` needs a login, `/abs` needs
credentials, `/youtube` needs its client to build, `/fs` needs a readable
root directory, and `/crabidy` needs a writable state directory.
## Today's providers
Listed in the order the library root serves them.
- **`/crabidy`** — where the server writes everything you save: saved queues,
bookmarks, and downloaded captures, backed by a content-addressed audio
store. It has its own page: [The crabidy store](./store.md).
- **[`/abs`](./providers/abs.md)** — audiobooks from a self-hosted
audiobookshelf server. Needs a server URL and an API key (`abs.toml`).
- **[`/fs`](./providers/fs.md)** — a local music folder: directories become
nodes, `*.cbd-track.toml` files become tracks. No login (`fsdy.toml`).
- **[`/fyyd`](./providers/fyyd.md)** — podcast search over fyyd's public
API: find a podcast, drill into its episodes, play them. No account of any
kind (`fyyd.toml`).
- **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of
Creative-Commons music, by search or album. Works out of the box on a
shipped app key; bring your own for your own rate limit (`jamendo.toml`).
- **[`/soundcloud`](./providers/soundcloud.md)** — SoundCloud search, link
resolving, and playback with no credentials at all; an optional token adds
your likes and playlists (`soundcloud.toml`).
- **[`/tidal`](./providers/tidal.md)** — a Tidal account: playlists,
favorite artists and their albums, mixes, and search. Interactive device
login on first start (`tidaly.toml`).
- **[`/youtube`](./providers/youtube.md)** — YouTube search, plus an
account's saved playlists when cookies are configured (`ytdy.toml`).
- **`/orphans`** — store audio that no save references any more, surfaced so
you can reclaim it (rename it into a save, queue it, or delete it). It is a
view over the store, not a separate source, so it comes and goes with
`/crabidy`.
Each provider page documents its tree, how to log in where that applies, and
every option in its config file.
Several providers expose a **search** subtree in which you create nodes
whose titles are your search terms; see [Search](./providers/search.md)
for how that works across providers.

79
docs/src/providers/abs.md Normal file
View File

@ -0,0 +1,79 @@
# audiobookshelf — /abs
<!-- toc -->
Mounts a self-hosted [audiobookshelf](https://www.audiobookshelf.org/)
server at `/abs`, so your audiobooks browse, queue, and play like any other
part of the library. Audiobooks only for now — podcast libraries on the ABS
server are not listed.
## Logging in
The provider needs two things in `abs.toml`: your server's base URL and an
**API key**. Without both, `/abs` does not mount and the rest of the server
carries on.
1. Open your audiobookshelf web UI as the user whose libraries you want.
2. Go to **Settings → Users → (your user) → API Keys** (on older versions:
**Settings → API Keys**) and create a key.
3. Put it in `~/.config/crabidy/abs.toml`:
```toml
base_url = "https://audiobookshelf.example.com"
api_key = "<the key you just created>"
```
4. Restart the server. `/abs` appears with one child per book library.
There is no interactive login and no token to refresh: the API key is a
long-lived bearer token. Revoke it in the same screen to cut access.
```admonish warning
The API key grants access to your audiobookshelf account. Crabidy redacts it
from logs and never prints it, but keep `abs.toml` private — it is stored in
cleartext, like every other provider credential.
```
## The tree
```text
/abs
└── <library> one node per "book" library
├── search create a term with `%`
│ └── <term> books matching the term
│ └── <book> the book's audio files as tracks
└── <book> an audiobook
└── <file> one audio file = one track
```
- A **library** lists its books plus a creatable `search` node. Search is
scoped per library: a term created under one library is not visible under
another. See [Search](./search.md).
- A **book** lists its audio files as tracks, in the server's order. Queue or
capture the whole book, or a single file.
- An item with no audio (an ebook-only entry) is listed but not queueable.
## Playback
Each file streams directly from audiobookshelf over HTTP with range
requests — no transcoding session is opened, and the audio is whatever the
server stores (commonly Opus or MP3; Opus needs the `opus` build feature, see
[Tailored builds](../build-features.md)). Captures (`W`) download the same
files into the content store.
## Configuration — `abs.toml`
```toml
# Required — without both of these the provider does not mount.
base_url = "https://audiobookshelf.example.com"
api_key = "<your audiobookshelf API key>"
# Optional, defaults shown.
items_per_library = 200 # books listed per library
search_results = 50 # books listed per search term
call_timeout_secs = 30 # per-request timeout
```
Listings are fetched fresh on every visit and capped by the values above.
Only your typed search terms are remembered, and only in memory until the
server restarts.

118
docs/src/providers/fs.md Normal file
View File

@ -0,0 +1,118 @@
# Filesystem — /fs
<!-- toc -->
The filesystem provider (crate `fsdy`) serves a single local music
folder under `/fs`. It does not read audio tags: instead it walks the
directory tree and treats small TOML files with a well-known suffix as
**serialized track nodes** — each one carries a track's metadata and a
reference to the thing that actually plays.
Folders become library nodes; `*.cbd-track.toml` files become tracks.
Everything else in the tree (other files, hidden entries, symlinks) is
ignored.
## Configuration — `~/.config/crabidy/fsdy.toml`
The file has a single field:
```toml
# Absolute path of the directory exposed under /fs. When unset, the
# platform music directory (dirs::audio_dir(), e.g. ~/Music) is used.
root = "/home/me/Music"
```
A missing or empty file is fine — the default root is used. A root that
does not exist yet is accepted; listing it simply fails until it
appears. If neither a configured root nor a platform music directory is
available, the provider disables itself with a warning rather than
taking the server down.
## The track-file format
A file named `<anything>.cbd-track.toml` inside the root is a track. The
schema is:
```toml
# Required.
title = "We Will Rock You"
# Optional; empty when omitted (web radio streams often have no artist).
artist = "Queen"
# Optional, in seconds.
duration = 122
# Optional.
[album]
title = "News of the World"
release_date = "1977-10-28"
# Required: exactly one playable (see below).
[playable]
file = "../flac/we-will-rock-you.flac"
```
The `[playable]` table must set **exactly one** of five fields:
- `file` — a local audio file (see resolution below).
- `url` — an `http(s)` URL streamed by the player.
- `link` — an absolute crabidy path owned by another provider, e.g.
`/tidal/artists/3634161/536243361`.
- `store` — the bare name of an entry in the content-addressed store,
written by download captures (see [The crabidy store](../store.md)).
- `skipped = true` — the track has no playable audio; playback skips it.
(`skipped = false` counts as unset.)
```admonish info
A file that is not valid TOML, or that sets zero or several playables,
is **skipped with a warning** at listing time. It never panics and never
poisons its sibling files — the rest of the directory still lists.
```
### How a `file` playable resolves
- An **absolute** path is used as-is.
- A **relative** path is joined onto the directory containing the track
file, so a whole music folder stays relocatable.
Existence is not checked while listing; a dead reference surfaces at play
time as the player's normal "cannot open" warning, and playback moves on.
### How a `link` playable resolves
When the provider builds a track from a `link` file, it sets the track's
path to the **link target**, not to the `/fs` path. From then on the
track *is* a track of the target's provider as far as the queue and
playback are concerned — the orchestrator's prefix routing does the rest.
Links resolve exactly **one hop**: `get_urls_for_track` on an `/fs` path
never follows a link, so a link whose target is itself a link file dies
at play time with a warning, and cycles cannot recurse. The metadata
shown is what the file says, not the target's live metadata.
## Path-to-disk mapping
Library paths mirror the directory tree: `/fs/<seg>/<seg>/…`, where each
segment is the percent-encoded file name (the same encoding search terms
use), so names with spaces, `%`, or unicode survive the path scheme.
Mapping a path back to disk is a **safe descent**: each segment is
decoded and rejected if it is empty, `.`, `..`, or contains a path
separator. The joined path is always a pure descent from the root, so a
client-supplied path cannot escape the configured folder. Symlinks are
skipped during listing, so they cannot lead out of the root either.
## Listing behavior
- Directories and track files are each sorted case-insensitively by file
name — a deterministic queueing order. Prefix file names with `01`,
`02`, … to order an album.
- Directories are queueable nodes; queueing one resolves its whole
subtree, one chunk per directory.
- **Nothing is cached.** Every navigation reads the directory fresh, so
edits made with an ordinary file manager appear on the next visit —
there is no file watching and no reload step.
Creating track files through the library is not supported here; they are
made and edited with normal file tools. (Other instances of the same
crate — the `/crabidy` saves — do support renaming and deleting; see
[The crabidy store](../store.md).)

View File

@ -0,0 +1,78 @@
# fyyd — /fyyd
<!-- toc -->
The fyyd provider (crate `fyyd`) mounts podcasts under `/fyyd`, backed by
[fyyd](https://fyyd.de)'s public podcast search engine
(`api.fyyd.de`). It needs no account or API key: you search for a
podcast, drill into its episodes, and play them.
## How it differs from the music providers
A music search returns tracks. A *podcast* search returns **podcasts**,
and each podcast is a container of **episodes**. So `/fyyd` carries one
level more than `/youtube`:
```text
/fyyd/search/<term> → matching podcasts
/fyyd/search/<term>/<podcast> → that podcast's episodes (tracks)
```
An episode is an ordinary track whose audio is the `enclosure` URL from
the podcast's feed — a plain media file the built-in player streams
directly. There is no sidecar, cipher solving, or byte-proxying.
## The tree
- `/fyyd/search` — always present. Press `%` to create a search term
(see [Search](./search.md)); the term node lists matching podcasts.
Terms are renamable (`e`) and deletable (`d`), live in memory, and are
recreated implicitly if you navigate to an old term path after a
restart.
- `/fyyd/hot` — always present. A fixed browse of fyyd's currently
featured ("hot") podcasts, so the provider is useful with no typing.
- `/fyyd/<branch>/<podcast>` — one podcast's episodes as tracks;
queueable and downloadable, so you can queue or capture (`W`) a whole
podcast at once (bounded, see below).
Track paths end in the fyyd episode id, which is also the wire track's
`provider_item_id` — a stable identity independent of the path, so the
same episode de-duplicates in the crabidy store (see [The crabidy
store](../store.md)).
## Configuration — `~/.config/crabidy/fyyd.toml`
fyyd's API needs no credentials, so the file is optional and every
option has a default:
```toml
# Podcasts listed per search term. Default: 20.
# search_results = 20
# Featured podcasts under /fyyd/hot. Default: 20.
# hot_count = 20
# Episodes listed per podcast (also a hard cap so a huge back catalogue
# cannot stall the library). Default: 100.
# episodes_per_podcast = 100
# Per-request timeout in seconds. Default: 30.
# call_timeout_secs = 30
# API base URL override (a mirror, or a test server). Default:
# "https://api.fyyd.de/0.2".
# base_url = "https://api.fyyd.de/0.2"
```
Every listing is fetched fresh per request; only your search *terms* are
kept, in memory. A client that fails to build disables only `/fyyd` with
a warning — never the server.
## Captures
Podcasts and their episode lists are downloadable: `W` captures a
podcast's episodes into `/crabidy`, the same way it works for any other
provider.
Everything the provider needs is public: fyyd requires no account, no key,
and no login of any kind.

View File

@ -0,0 +1,79 @@
# Jamendo — /jamendo
<!-- toc -->
Mounts [Jamendo](https://www.jamendo.com) at `/jamendo`: search a large
catalogue of Creative-Commons licensed music, play it, and browse the album a
track belongs to.
## Logging in
There is nothing to do. Jamendo has no user login — its public catalogue
needs only an **app key** (`client_id`) that identifies the *application*, not
you, and crabidy ships a working default. `/jamendo` mounts out of the box,
and the key it used is written into `jamendo.toml` so you can always see which
one is in force.
You may still want your own key, because Jamendo's rate limit is **per key**:
a shipped default is a shared budget, and a heavy user is better off with
their own.
1. Register at [devportal.jamendo.com](https://devportal.jamendo.com) and
create an application.
2. Copy its **Client ID**.
3. Put it in `~/.config/crabidy/jamendo.toml`:
```toml
client_id = "<your jamendo client id>"
```
4. Restart the server. A configured key always wins over the default.
No token refresh, no expiry, nothing to maintain. The key is treated as a
semi-secret: it is redacted from logs, but keep the file private.
## The tree
```text
/jamendo
├── search create a search term with `%`
│ └── <term> matching tracks
├── track/<id> a single track (canonical address)
└── album/<id> an album and its tracks (canonical address)
```
`search` is a creatable node: press `%` and type a term, and the term becomes
a child node holding its results. Rename it (`e`) to re-run the search,
delete it (`d`) to drop it. Terms live in memory only, so a restart forgets
the list. See [Search](./search.md).
Tracks and albums are addressed by id whatever browse node you reached them
through, so a queued track keeps working after the search term that found it
is gone. From a track you can navigate to its album to hear the rest of it.
## Playback
Jamendo hands out a direct MP3 URL per track, which the player streams over
its ordinary HTTP path — no HLS, no helper binary, and seeking works.
Captures (`W`) download those files into the content store.
The default `audioformat` is `mp31`, Jamendo's freely streamable MP3. `mp32`
(higher bitrate) is not reliably provisioned for streaming and comes back
empty for many tracks, so it is a poor default; a Pro account can set it
explicitly. If a track has no audio in the requested format the provider
falls back rather than failing the listing.
## Configuration — `jamendo.toml`
```toml
# The app key. Unset (or blank) uses the key crabidy ships with, which is
# written back here on first start so you can see what is in use. Replace it
# with your own from devportal.jamendo.com to get your own rate limit.
# client_id = "..."
# Optional, defaults shown.
audioformat = "mp31" # "mp31" (default) or "mp32" (see above)
search_results = 50 # tracks per search term (Jamendo caps at 200)
album_tracks = 200 # tracks listed per album
call_timeout_secs = 30 # per-request timeout
```

View File

@ -0,0 +1,55 @@
# Search
<!-- toc -->
Crabidy has no search dialog. Searching is done by **editing the library
tree**: inside a provider's search subtree you create a node whose title
is your search term, and that node holds the results. The created node is
persistent — you can revisit it, queue from it, and (for providers that
allow it) rename or delete it — so a search is just another place in the
tree.
## Creating a search node
A node that accepts creation is marked **creatable**. When the currently
open node is creatable, press `%`: a one-line input opens at the bottom
of the library pane, you type a term and press `Enter` (or `Esc` to
cancel), and the term is sent to the provider as the title of a new
child node. The client then navigates into that node.
```admonish info
`%` acts on the **open** node, not the selected child — you enter a
search subtree (e.g. `/tidal/search`) and press `%` there. Creatable
nodes render with a `%` marker in the child list, and the pane title
shows a hint when the open node itself is creatable, so the affordance
is visible without documentation.
```
Creating a term that already exists returns the existing node rather than
erroring; empty or whitespace-only terms are rejected. Terms containing
`/` or `%` are safe: the term is percent-encoded into the path segment
while the node keeps the raw term as its display title.
## What the results look like
The shape depends on the provider:
- **[Tidal](./tidal.md)** — `/tidal/search/<term>` carries track results
directly (queueable in place) and lists artist and album results as
children whose paths are canonical (`/tidal/artists/<id>`, …), so
diving into an artist reuses the normal browse machinery. The term
node itself is not queueable.
- **[YouTube](./youtube.md)** — `/youtube/search/<term>` lists the top
video results as tracks and *is* queueable, since the results are
homogeneous.
In both cases results are a live view, fetched fresh when the node is
opened — nothing is cached. Terms live in memory for the life of the
server process.
```admonish note
This tree-editing search is distinct from the terminal client's `/`
**live filter**, which narrows the children already shown in the current
node without fetching anything. See the [Terminal UI](../clients/tui.md)
for that.
```

View File

@ -0,0 +1,92 @@
# SoundCloud — /soundcloud
<!-- toc -->
Mounts SoundCloud at `/soundcloud`: search the public catalogue, paste a
track or playlist link to resolve it, and — if you configure a token — browse
your own likes and playlists.
## Logging in (optional)
SoundCloud is the one provider that works with **no credentials at all**.
Public browsing and playback need only a `client_id`, which the provider
scrapes from SoundCloud's own web app on first start and then writes back
into `soundcloud.toml` so later starts reuse it. Nothing to do.
A login only adds two nodes — `likes` and `playlists`. To enable them, put an
OAuth token in `~/.config/crabidy/soundcloud.toml`:
```toml
oauth_token = "<your soundcloud oauth token>"
```
The token is the value SoundCloud's own web app uses for your session; read
it out of a logged-in browser (the `oauth_token` cookie on `soundcloud.com`,
or the `Authorization: OAuth …` header on any API request in the network
tab). There is no device-code flow — SoundCloud does not offer public API
registration any more, so this is the available route.
Restart the server after adding it: `likes` and `playlists` appear at the
provider root. If the token is rejected or expires, those two nodes are the
only thing lost — search, resolve, and playback keep working.
```admonish warning
The token is a live session credential for your account. It is redacted from
logs and from every debug dump, but `soundcloud.toml` holds it in cleartext —
keep the file private, and revoke the session in SoundCloud's settings if it
leaks.
```
## The tree
```text
/soundcloud
├── search create a search term with `%`
│ └── <term> matching tracks
├── resolve create an entry from a soundcloud.com URL
│ └── <url> the track or playlist that URL points at
├── likes your liked tracks (only with a token)
└── playlists your playlists (only with a token)
└── <playlist> its tracks
```
Both `search` and `resolve` are creatable nodes: press `%` and type a search
term or paste a URL. The entry becomes a child node you can rename (`e`,
which re-runs it) or delete (`d`). Entries live in memory, so a server
restart forgets the list — navigating back to a remembered path recreates it.
See [Search](./search.md).
Tracks and playlists are addressed canonically by id
(`/soundcloud/track/<id>`, `/soundcloud/playlist/<id>`), whichever browse
node you reached them through, so a queued track keeps working after you
delete the search term that found it.
## Playback
SoundCloud serves audio as **HLS**: the provider resolves a signed `.m3u8`
media URL and the player streams its MP3 segments in order as one continuous
stream. That URL is a short-lived secret and never appears in logs. Seeking
within an HLS track is not supported (the stream is forward-only); everything
else — play, pause, next, capture — behaves normally.
## Configuration — `soundcloud.toml`
```toml
# Scraped and written back by the provider; you normally never touch these.
# Delete them to force a fresh scrape on the next start.
# client_id = "..."
# app_version = "..."
# Optional login: adds the `likes` and `playlists` nodes.
# oauth_token = "..."
# Optional, defaults shown.
search_results = 50 # tracks per search term
playlist_tracks = 500 # tracks hydrated per playlist
call_timeout_secs = 30 # per-request timeout
hls_deadline_secs = 300 # total deadline for fetching one HLS stream
```
If SoundCloud rotates its web app and the stored `client_id` stops working,
the provider re-scrapes on the next start; if it cannot obtain one at all,
`/soundcloud` is dropped with a warning and the server runs on.

View File

@ -0,0 +1,66 @@
# Tidal — /tidal
<!-- toc -->
The Tidal provider (crate `tidaldy`) mounts a Tidal account under
`/tidal` and streams from Tidal's web API. It offers your playlists,
favorite artists with their albums and tracks, mixes, and a search
subtree.
## Login
On first start the provider runs Tidal's OAuth **device login**: the
server prints a `link.tidal.com` verification URL to its stdout (for the
`cbd` bundle, look in the log file). Open it, authorize, and the provider
finishes logging in by itself. The obtained tokens are written back into
the config file and refreshed automatically from then on, so you should
not need to log in again.
## Configuration — `~/.config/crabidy/tidaly.toml`
The file is rewritten on every server start with the current values,
including refreshed tokens.
```admonish warning
`tidaly.toml` contains your credentials — keep it private. A missing or
empty file is fine: defaults are used and the device login runs on the
next start. To force a fresh login, delete the `[login]` section and
restart.
```
The main setting you might change is `audio_quality` (`"Low"`,
`"High"`, `"Lossless"`, or `"HiRes"`); the API endpoints and OAuth
client identity have working built-in defaults. See `tidaldy/README.md`
in the source tree for the full field list.
## Browsing and search
The tree offers your playlists, favorite artists (drilling into albums
and tracks), mixes, and a search node. Under `/tidal/search`, press `%`
to create a search term; the term becomes a persistent child node
holding its results — tracks in place, plus artists and albums as
children. Terms are renamable with `e` (which re-runs the search) and
deletable with `d`. See [Search](./search.md) for the shared mechanism.
## Stable track identity
Each Tidal track carries its Tidal track id in the wire track's
`provider_item_id`. Because that id is stable and independent of the
path a track was reached by, the same track de-duplicates in the crabidy
store no matter whether you queued it from a playlist, an album, or a
search result. Bookmarks and downloaded captures therefore reference one
copy of a track rather than one per path (see [The crabidy
store](../store.md)).
## Streaming
Everything queueable is also capturable: `w` bookmarks a subtree as
links, and `W` downloads a subtree's audio into `/crabidy`.
```admonish note
Tidal stream URLs are short-lived, tokenised, and windowed on download.
They are treated as secrets and kept out of logs and error reports.
```
For endpoint specifics and the complete configuration reference, read
`tidaldy/README.md` in the repository.

View File

@ -0,0 +1,89 @@
# YouTube — /youtube
<!-- toc -->
The YouTube provider (crate `ytdy`) mounts YouTube under `/youtube`. It
offers a search subtree that works without any login, and — when a
cookie login is configured — the account's saved playlists.
## Extraction engine: rustypipe (in-process)
All **metadata** — search, playlist listings, and video details — comes
from the pure-Rust [rustypipe](https://crates.io/crates/rustypipe)
Innertube client, running in-process. There is no `yt-dlp` (or other
Python) subprocess involved in extraction.
```admonish info
Stream *URLs* are the one exception. YouTube currently caps tokenless
stream URLs at their leading ~1 MiB, and a minimal `yt-dlp` sidecar is
the only maintained solver for the cipher challenges that lift the cap.
So `get_urls_for_track` consults `yt-dlp` when the binary is available;
everything else stays on rustypipe. Without the binary the provider
still works, but playback of a track stops after roughly its first
~1 MiB. This is a documented concession, not the extraction path.
```
Streams prefer the `audio/mp4` (AAC) format the built-in player can
decode, so downloaded captures store `.m4a` files.
## Configuration — `~/.config/crabidy/ytdy.toml`
All options are optional:
```toml
# Netscape cookies.txt export for logged-in features (saved playlists).
# Default: unset (logged out; search still works).
# cookies = "/home/me/youtube-cookies.txt"
# Results per search term. Default: 20.
# search_results = 20
# Per-request timeout in seconds. Default: 30.
# call_timeout_secs = 30
# The yt-dlp binary used ONLY for stream URLs (PATH lookup by a bare
# name). Default: "yt-dlp".
# binary = "yt-dlp"
# Optional rustypipe-botguard binary for PO-token attestation (also
# auto-detected on PATH). Default: unset.
# botguard_bin = "/home/me/.cargo/bin/rustypipe-botguard"
```
```admonish warning
The cookies file and the rustypipe client cache (under
`~/.config/crabidy/rustypipe/`) both grant access to your YouTube
session — keep them private. The provider logs only their paths, never
their contents, and googlevideo stream URLs are throttled, tokenised,
windowed on download, and redacted from logs.
```
The provider caches the rotated session cookie, so a cookies export only
needs to be valid once. Rejected or missing cookies degrade to
logged-out with a warning; a broken client at startup disables only
`/youtube`, never the server.
## The tree
- `/youtube/search` — always present. Press `%` to create a search term
(see [Search](./search.md)); the term node lists the top
`search_results` videos as tracks and is queueable, since the results
are homogeneous tracks. Terms are renamable (`e`) and deletable (`d`),
live in memory, and are recreated implicitly if you navigate to an old
term path after a restart.
- `/youtube/playlists` — present only when a cookie login is accepted;
lists the account's saved playlists, each drilling into its tracks.
Track paths end in the video id, which is also carried in the wire
track's `provider_item_id` — a stable identity independent of the path
the track was reached by, so the same video de-duplicates in the crabidy
store (see [The crabidy store](../store.md)).
## Captures
Search results and playlists are downloadable: `W` captures a subtree's
audio into `/crabidy`. Under the stream throttle this is slow, but
captures are resumable — re-capture the same name to continue.
For the crate-level notes on the rustypipe/`yt-dlp` split, see
`ytdy/README.md`.

Some files were not shown because too many files have changed in this diff Show More