`opus` conflated two axes: decoding Ogg-Opus, and vendoring the C library
to do it. So the only way to avoid a cmake build of libopus was to give up
Opus playback -- the wrong trade for Nix, which already ships one.
The adapter crate draws the line already: symphonia-adapter-libopus own
`bundled` feature is what pulls opusic-sys/bundled and with it cmake. So
declare the dependency `default-features = false` and add
`opus-bundled = ["symphonia-adapter-libopus?/bundled"]`, forwarded up
through crabidy-server and cbd. It stays in every `default`, so a plain
cargo build still needs nothing installed; opting out is the packager act.
The weak `?/` is load-bearing -- a plain `/bundled` would enable the
optional dependency itself, and `opus-bundled` would quietly become a
second "do we decode Opus" flag.
The flake native build then drops cmake and takes libopus from nixpkgs;
`headlessFeatures` already omitted opus-bundled, so it opts out for free.
The cross build keeps the vendored copy: it links statically, and an
unbundled -lopus would need a static aarch64 libopus staged for the target
the way alsa-lib is.
That exposed an older bug. rustc stamps no RUNPATH, and the -L from
buildInputs arrives through NIX_LDFLAGS, which ld-wrapper does not mirror
into the binary -- so the package linked cleanly and then refused to start.
RUNPATH was empty, meaning libasound.so.2 had never resolved either: the
package always depended on the caller having it on LD_LIBRARY_PATH, which
this repo dev shell happens to set. autoPatchelfHook now fills the RUNPATH
from buildInputs (plus stdenv.cc.cc for libgcc_s, the compiler own
unwinding runtime, which no crate declares) and fails the build on anything
it cannot resolve. Verified by running each binary under `env -i`, and with
LD_BIND_NOW=1 so every opus symbol binds eagerly.
Also: devenv sets OPUS_LIB_DIR, without which the unbundled build dies as
"mold: fatal: library not found: opus"; check-features *builds* the two
libopus variants rather than clippy-ing them, since clippy links nothing and
cannot tell a resolvable -lopus from a missing one; and cbd was missing an
`rss` pass-through, so the bundle could not select that provider alone.
Requested alongside this: the spectrum shadows fall over 10 seconds instead
of 4. The config default and SpectrumStyle::default are two spellings of
one thing, so the config test now asserts the whole resolved style equals
SpectrumStyle::default() rather than field-by-field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The volume display read 0% always, because the level never reached a
client. Three holes, each sufficient on its own:
- `Init` hardcoded `volume: 0.0`, `mute: false` and a zeroed position.
- `ChangeVolume` broadcast nothing, so no client learned the level had
changed and the display could not recover from that init value. The web
slider was equally silent and never tracked J/K.
- `PlaybackCommand::VolumeChanged`/`MuteChanged` are handled but sent by
nobody — a broadcast path that looks real and does nothing, which is
presumably how the above went unnoticed. Left alone here.
`ChangeVolume` now broadcasts the level the engine actually took, so
clients see the 1.1 clamp rather than what they asked for, plus
`Mute(false)`, since `set_volume` unmutes and the indicator would
otherwise stick.
The hardcoding had a cause: the init response is built while holding the
queue's std Mutex guard, which cannot be held across an await. The player
reads now happen before the lock is taken, each with a 1 s budget —
`Init` is the connect path, and the engine can be 30 s deep in opening a
stream, so a client must get a usable snapshot rather than hang.
Fixing the init position also fixes click-to-seek against a paused
server: no position ticks flow while paused, so the web client sent
`target - 0` and the engine added it to the real position.
Adds `Player::is_muted`, since only `toggle_mute` existed and that cannot
ask without changing the state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audio engine could already seek and nothing called it: no rpc, no
playback command, no binding. This wires it from every client.
The one real decision was where the arithmetic lives. A seek is relative
but the engine seeks to an absolute position, so either the client computes
a target from the last position update or it sends an offset and the engine
adds it to the live position. The offset wins on the ordinary case of
pressing the key twice: positions are broadcast on a 250 ms tick and then
cross the network, so three quick presses would all read the same stale
base and jump 15 s instead of 45. It also keeps the clamping policy in one
place instead of three clients, and matters more while paused, where no
position updates arrive at all.
So the wire carries sint32 delta_millis and the step is a client constant.
It also uncovered a live panic: seek_to did
`time.clamp(Duration::from_secs(1), duration)`, and `Ord::clamp` asserts
min <= max while `duration()` returns 0 for any source that reported no
length (HLS, some streams). That panicked the engine thread, killing audio.
Unreachable only because nothing called it; wiring seek made it reachable
from user input. It is now saturating arithmetic in a pure, exhaustively
tested function.
Boundaries: backwards saturates at 0 and never enters the previous track;
forwards stops 1 s short of the end so the track finishes through the
ordinary end-of-stream path (which advances the queue) instead of relying
on seek-to-exact-end, which decoders disagree about; an unknown duration
has no upper clamp. The engine emits Elapsed from the seek path itself,
because tick() skips a paused sink and a paused seek would otherwise show
the old position until playback resumed. An unseekable source (SoundCloud
HLS) warns server-side and changes nothing.
Ctrl-b/Ctrl-f join the existing control-chord family; plain f still toggles
the spectrum because lookup compares every modifier but SHIFT exactly. In
the browser Ctrl-f would open the find bar, but the keydown handler already
prevent_defaults any chord that resolves.
Seek is deliberately not tested through the playback loop: every test there
builds a real Player whose engine thread opens an audio device, so a test
that awaits a player reply passes or hangs depending on whether the machine
has working audio. The arithmetic is tested as a pure function, and the
rpc -> command mapping (the layer the paste bug lived in) in rpc.rs.
Verified: 20 audio-player tests (5 new: i64::MIN/MAX, zero duration,
sub-second tracks, composition, near-end saturation), 95 crabidy-server,
119 cbd-tui, 21 cbd-web, 58 server tests with --no-default-features,
workspace clippy clean under -D warnings, fmt clean, wasm bundle and book
build. Not exercised: an actual seek through an audio device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
Toggling mute did nothing: the server logged a FIXME and never touched
the player, and the TUI ignored the Mute stream update. Now the player
engine mutes by zeroing the sink volume and remembering the level to
restore (setting the volume unmutes), ToggleMute drives it and
broadcasts the new state, the TUI shows a Muted marker in the
now-playing pane, and the web client mute button already reflected the
Mute update so it now works too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A row of frequency bars under the track progress, on by default and
toggleable with the client spectrum config option. Because the audio
plays on the server and clients may be remote, the spectrum is produced
server-side, not captured locally: audio-player taps its own output
into a lock-free ring on the audio thread (one store per sample, no
locks), crabidy-server runs a Hann + realfft over 2048 samples at 20fps,
folds it into log-spaced bars, and broadcasts them as a new SpectrumFrame
on the update stream. The task idles when nothing is playing or no
client is listening. The TUI renders block-glyph bars in the now-playing
pane; the web client renders the same bins as CSS bars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TUI cached every library listing for the whole session, so a
finished capture (or a saved queue) never appeared under /captures
until a restart — captures looked broken while they had succeeded on
disk. Listings under /captures, /queues, /bookmarks, and /fs are now
always refetched (cheap local walks on the server); remote provider
nodes keep the instant back-navigation cache.
The player engine also logged full stream URLs (including googlevideo
sig tokens) through its play span; sources are now logged as
scheme://host only, local paths verbatim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
YouTube caps tokenless stream URLs at exactly their leading 1 MiB:
plain, open-ended, and oversized requests get 403, and fresh URLs
refuse offset starts, so playback died mid-first-minute. PO tokens
would lift the cap but the token-capable Innertube clients need
signature deciphering that is broken in rustypipe upstream (botguard
was built and tested — ineffective through the iOS client).
The player now streams every http(s) source through a windowed
SourceStream (bounded ~1 MiB ranges, 200-body fallback, eager
seek/reconnect so rejected windows fail typed instead of retrying
forever) and the capture downloader windows the same way. Stream URLs
come from a minimal yt-dlp sidecar again — metadata stays on the
pure-Rust rustypipe extractor — whose cipher-solved URLs stream whole
files at a throttled ~32 KB/s; a missing binary degrades to 1 MiB
streams with a warning. botguard_bin is wired through so streams flip
back to pure Rust when upstream deciphering recovers. Live-verified on
the exact track from the failure log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Playing a network track panicked in rodio 0.22's symphonia backend:
"Seek errors should not occur during initialization". During init
symphonia probes the container length; on a source with no known byte
length it seeks from the end, and StreamDownload handles that in a way
rodio turns into an `unreachable!`. This surfaced after the dependency
bump from rodio 0.17 (with the old custom SymphoniaDecoder) to 0.22.
Pass the stream's content length to the decoder via with_byte_len (the
local-file path already did this), which lets symphonia skip the
end-relative seek entirely. Verified against a real Tidal AAC/mp4
stream: the streamed decode now succeeds instead of panicking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root causes found and fixed:
- QueueManager could panic and kill the playback task permanently:
is_last_track() underflowed on an empty queue, remove_tracks accepted
pos == len (Vec::remove panic) and corrupted positions when removing
multiple tracks (indices shifted mid-loop), shuffle_behind indexed
out of range on an empty play order, insert_tracks shifted play-order
entries by the queue length instead of the inserted count and then
assert!()ed on the resulting inconsistency, and clear() left
play_order stale. All mutation methods are now guarded, multi-remove
works highest-position-first, and an inconsistent play order is
rebuilt instead of panicking. Regression tests cover these cases.
- The tidal access token was only obtained at startup and never
refreshed, so long-running sessions ended with every track fetch
failing (playback just stopped at the next track boundary). Login
state now lives behind a lock; tokens are refreshed proactively
before expiry (5 min margin) and once reactively on a 401, and all
API responses are status-checked (new ClientError::ApiError) instead
of being fed to the JSON decoder blind. The http client also got a
30s timeout so a hung connection cannot wedge the provider loop.
- (from the rodio rewrite, same bug class) end of stream used to be
detected by string-comparing an io::Error message; any other decode
or network error ended the stream silently without an EndOfStream
message, so playback never advanced. EOS is now a guaranteed
callback with a generation counter.
Plus workspace-wide clippy cleanup (zero warnings), cargo-machete
cleanup, and fmt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tonic 0.9 -> 0.14 (tonic-prost/tonic-prost-build split), prost 0.14
- ratatui 0.20 -> 0.30 (Frame no longer generic, Line instead of Spans),
crossterm 0.29
- rodio 0.17 -> 0.22: replace the custom symphonia decoder with rodio's
built-in decoder, seeking (try_seek) and position tracking (get_pos);
end-of-stream is now signalled via an EmptyCallback source with a
generation counter so a replaced track can never emit a stale EOS
- replace the vendored stream-download crate with the published
stream-download 0.24 (rustls), with a 30s open timeout
- reqwest 0.12->0.13 (rustls/webpki-roots/query features), base64 0.22
Engine API, rand 0.10, flume 0.12, thiserror 2, dirs 6, toml 1
- unify everything under [workspace.dependencies]; drop unused deps
(once_cell, serde_json in server; confique, secrecy in tidaldy)
- devenv: add protobuf (protoc) for prost-build
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>