Commit Graph

247 Commits

Author SHA1 Message Date
Test User 1f41c45b95 plan: record what the live queue and the log actually said
The measurements behind the second pass — 121 entries with 121 distinct
provider ids, 13 same-title groups all differing by tens of seconds, 3664
spectrum flaps ~1s apart, and one mislabelled shutdown error — plus the
one fix deliberately left out (the UI-thread unwraps).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:42:39 +02:00
Test User bb084dd62b queue, tui: dedup by title, and keep foreign writes off the screen
Four findings from a day's cbd.log and a 121-entry queue.

**Dedup by title.** The provider-item identity removed nothing from that
queue — 121 entries, 121 distinct ids — while three "Sink Into The Hips"
sat in it: two remixes and the album version, 171/189/228s. Those are
different recordings and merging them by default would silently discard a
version the user chose, so the default stands and `DedupQueue` gains a
`by_title` flag: lowercased (artist, title), survivor = the playing entry
else the *longest* take. Own key everywhere (`U`, `queue dedup --titles`)
because it throws recordings away. Duration-tolerant matching was the
third option and is not worth it: every same-title group in that queue
differed by tens of seconds, so a safe tolerance caught nothing.

**stderr no longer points at the terminal.** tracing goes to a file
because the TUI owns the screen, but fd 2 did not — and in the bundled
`cbd` the ALSA C library shares the process, so its "underrun occurred"
printed straight onto the interface, scrolled the terminal a line and
left the layout looking shifted (the queue appearing to bleed into the
now-playing pane; ratatui repaints only changed cells, so it persisted).
The message was lost too. One dup2 before the alternate screen sends fd 2
to `<log dir>/cbd.stderr.log`, so those diagnostics are kept instead.

**The spectrum flapped ~1/s during playback**, 3664 times in one log.
tokio's default MissedTickBehavior::Burst keeps the absolute schedule, so
once the per-tick FFT lateness reaches a whole period two ticks fire back
to back and the second necessarily sees no new frames — read as silence,
which zeroed the bars. Now `Delay`, plus a FlowDetector that wants two
consecutive empty ticks before declaring idle.

**The one ERROR in the log was a shutdown race**, mislabelled: "request
to server failed: sending on a closed channel" was the orchestrator's
send to the UI channel after the UI thread exited. It now reports the UI
closing at info and stops the loop instead of spinning on a stream nobody
reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:42:04 +02:00
Test User 001abcc35c queue: de-duplicate and sort the queue, server-side
Two verbs that rewrite what the queue already holds instead of adding to
it: DedupQueue and SortQueue, on the playback loop, for every client.
Client-side was the tempting shortcut and the wrong one — a
client-computed Remove races the resolve stream, and "sorting" as a
Replace of paths re-resolves everything through providers and restarts
playback at the head.

A duplicate is the same provider item id *scoped to its provider* (the
content store keys content the same way, so unscoped ids let two
providers' numeric ids collide), or the whole path when a provider
reports no id. Deliberately not artist+title: identical metadata is
routinely a different recording, and a wrong merge is unrecoverable
queue state. Within a group the playing entry survives, else the
earliest — "keep the first" stops the music whenever the playing copy is
a later one — and the removal itself goes through remove_tracks, so one
code path maintains play_order.

Sorting permutes `tracks` and then treats the play order by mode: with
shuffle off it rebuilds it as the identity with the cursor on the current
track (the sort decides what plays next), with shuffle on it remaps it
through the permutation so the shuffled sequence and the position in it
survive. Five stable strategies (artist→album, album, title, duration,
reverse), keys built once per track, unknown sorting last in both
directions, and UNSPECIFIED refused as InvalidArgument.

DedupQueue answers with a count, alone among the queue verbs: 0 cannot be
recovered from the new snapshot and is what a user needs to hear. It
crosses as a number — the wording is each client's. TUI: `u` and a modal
`S` sort menu whose unclaimed keys are swallowed, plus the count in the
queue pane title. Web: the same keys, clickable menu rows, toolbar
buttons, count in a toast. CLI: `queue dedup` and `queue sort <key>
[--desc]` with the strategy as a ValueEnum.

Full dev-flow: architecture/queue-order.md, quality/queue-order.md,
plan/queue-order.md, plan/summary.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:50:17 +02:00
Test User 1228c7cb70 cbd-tui: show the album year, not the full release date
A notification has one line for the album, and "album (1977-10-28)"
spends it on a day nobody asked about.

`release_date` is whatever the provider's API (or fsdy's sidecar TOML)
said and is not always ISO 8601, so `release_year` reads the leading four
digits of a year-first date and returns None for anything else — an
unparseable date is dropped rather than shown raw or half-parsed, which
also keeps the "no date, no parentheses" path doing the work. `get(..4)`
is the safe form: a short string or a multi-byte boundary yields None
instead of panicking, and the tests pin both down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:57:36 +02:00
Test User bbebee0b4f cbd-tui: no empty parentheses in the now-playing notification
`Album.release_date` is an optional proto field and most providers never
set it, so the notification body read "album ()" for nearly every track:
the parentheses were part of the format string rather than of the date.

Move the body into `notification_body`, where the parentheses belong to
the date and an absent one (unset or empty, since prost's accessor
returns "" for both) simply drops them, and cover the three shapes with
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:15:17 +02:00
Test User 2bfdac25a7 nix: name the crane derivations, instead of "cargo-package"
The root manifest is a pure [workspace], so crane finds no [package].name
to name its derivations after and falls back to a placeholder — every
build of crabidy logged two "crane will use a placeholder value" warnings
and produced cargo-package-0.1.0.drv, which says nothing about what was
built.

Name it in [workspace.metadata.crane], the spelling crane itself
suggests. Cargo ignores unknown workspace metadata, so this is a build
label and nothing more.

The cross build takes its name from the same root, which would leave the
native and aarch64 derivations both called crabidy-0.1.0; give it an
explicit pname so a build log can tell them apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:11:20 +02:00
Test User a2e3da6aa5 cbd-tui: speak MPRIS, so the media keys reach the server
The recollection that this used to exist is false — `git log --all -S
mpris` finds nothing on any branch. What has always been there is the
desktop *notification* on a track change, which is D-Bus but not MPRIS: a
popup is neither a status-bar entry nor a key target. So this is new, not
a regression.

Everything the protocol needs was already on the wire, so cbd-tui gains a
second front-end onto the two channels it already has: stream updates in,
MessageFromUi out. The MPRIS player is a peer of the UI thread — it
commands the server through the very channel the keybindings use, and it
learns the result the way the UI learns about a keypress from another
client. No proto change, no server change.

The decisions worth knowing (architecture/mpris.md):

- An absolute protocol over a toggling server. Play/Pause/SetShuffle/
  SetLoopStatus consult the last state the server broadcast and send
  nothing when it already matches, or the pause key would start playback
  on a paused player. Volume is the same idea with arithmetic; muting is
  spelled "volume 0", and the setter mutes on a zero target so the level
  survives to be unmuted to.
- No URL reaches the bus. xesam:url would have to be the stream URL,
  which clients never see and which several providers sign with
  credentials, and every peer on a session bus can read properties. The
  trackid is the queue position — also the only spelling that is a valid
  object path.
- mpris:length is omitted when unknown rather than sent as zero, which
  would make consumers draw a full progress bar.
- Unrepresentable requests are refused, not approximated: repeat-one,
  rates other than 1.0, OpenUri, Raise, and Quit — a status-bar button
  has no business closing someone's terminal.
- No session bus is a normal way to run (ssh, a tty, a container): the
  connection carries a timeout and its failure is an info log, after
  which the client behaves exactly as before.

Behind the `mpris` feature, on by default beside `notifications` and
forwarded by `cbd`; the nix package names it in headlessFeatures, since
naming a feature set at all replaces the crate defaults. It costs one
crate and no system library — zbus speaks D-Bus in pure Rust and
notify-rust had already brought it in.

Verified with the real thing, not only a test double: under
dbus-run-session, playerctl lists the player, reads its metadata
("Playing: the artist - the song (4:00)"), and drives play-pause,
`position 30+` and `volume 0.8` into the right commands. It also refuses
`next` when the queue is empty, which is CanGoNext being honest. The
committed bus test covers the round trip and skips where there is no bus.
2026-07-28 01:27:28 +02:00
Test User 621823d930 nix, opus: let the packager choose where libopus comes from
`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>
2026-07-28 00:24:03 +02:00
Test User 87b98193d8 docs: audit the book and the READMEs, and give Seek its role
Sweep of docs/ and every README against the code, after several features
landed since the last one.

Mechanical checks, which find what reading does not: SUMMARY covers every
page and no more; every relative link resolves (docs/src/providers.md
pointed a directory above the book, twice); every #anchor matches a real
heading; every provider crate's Settings field is documented in both its
README and its book page; the feature table matches Cargo.toml, which it
did not -- `rss` was missing and "seven provider features" is now eight.

Prose that predated recent work: the root README's client-config sample
knew only `spectrum` and still claimed every option has a flag; its
spectrum and web-client sections predated the colors, segments, shadows,
pane tabs, register and seek; cbd-web/README.md likewise; intro.md's
provider tree was missing /rss; clients.md omitted volume and mute from
what the update stream carries; rssdy/README.md did not mention that only
audio enclosures become episodes.

The audit also found a defect the docs were right about: `Seek` was never
added to `minimum_role`, so it fell through to the owner-only default
while auth.md and architecture/roles-auth.md both promise a queue-owner
may control playback. With auth configured a queue-owner could play, skip
and change the volume, but got PermissionDenied on `,`/`.`. Seek now sits
with the other playback verbs.

The test meant to prevent that -- "a new RPC must be added to exactly one
list" -- compared the role lists against a hardcoded 24, so a 25th method
kept the suite green. It now reads the method names out of crabidy.proto
and compares sets: a count copied out of a file is not a check against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:19:59 +02:00
Test User d8bd06219c docs: correct why the spectrum seams stay fixed and the bars vary
The note gave the reasoning for the opposite of what the code does: it
holds the seams at bar_gap and lets the bar widths absorb a pane that
does not divide evenly, not the other way round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:35:15 +02:00
Test User d25620c6a9 cbd-tui: give the spectrum segments, seams and filled shadows
Follow-up on the shading and peaks, plus the knobs to tune them.

The gradient now ends at a configured `spectrum_top_color` -- the secondary
purple -- rather than blending toward white, so the top row *is* the top
color. "none" ramps brightness alone.

A peak now fills the gap between the bar and its held maximum instead of
marking it with a rule, so each bar trails a shadow of where it just was.
`spectrum_peak_fill = false` keeps the rule, which is also the fallback
for a font without U+2594.

The dividers make the segments legible without breaking the field apart:
the seam between two bars is the bar itself at 35% brightness rather than
an empty column, and `spectrum_row_gap` eighths are left unlit at the top
of every cell so a full row draws `▇` and each value row reads on its own.
`spectrum_bar_width` is a minimum -- bars take the spare columns of their
slot and seams stay exactly `spectrum_bar_gap` wide, because an uneven bar
reads as an uneven level while an uneven seam is only untidy. Keeping the
seam inside the bar's own cells means all 24 bins still fit a normal pane.

Peaks fall in seconds now, not frames: `update_spectrum` times the interval
between frames and `spectrum_peak_fall` says how long a full-scale shadow
takes to reach the floor (4s, up from an effective 1.7s). The server's frame
rate is its own business, and a per-frame decay silently retunes itself when
it changes. `advance_spectrum` takes the interval so the fall is testable
without a clock, and the renderer floors the divisor itself rather than
trusting the config clamp -- a zero would freeze every shadow on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:27:28 +02:00
Test User d5b35a4da2 cbd-tui: shade the spectrum bars and hold their peaks
Modelled on BeSpec's LED mode. Bars are now shaded by height rather than
drawn in one flat color, and a peak-hold marker rides above each column.

Shading is a function of the row alone, so it stays cheap: the color is
computed once per row and the row's cells still coalesce into a couple of
spans. `gradient_color` ramps the configured base from 55% brightness at
the floor to 30% toward white at the top, so a loud bar reads as hot and
not merely tall. Only an `Rgb` base can be interpolated -- a color name or
a palette index is a reference into the terminal's own theme, whose RGB
value is not ours to know -- so those render flat, whatever
`spectrum_gradient` says.

The peaks are per-bin state advanced in `update_spectrum`: a bin at or
above its peak raises it at once, otherwise the peak falls 0.03 a frame,
about 1.7s from full scale at the server's 20 fps. The server keeps
streaming zeroed frames while the audio is idle, so the markers fall away
on pause instead of freezing on screen. A marker is drawn only in a row
the bar does not reach: a cell holds one glyph, so one inside the bar's
own top cell would eat the bar to repeat what its top edge already shows.

Config gains `spectrum_gradient` (default true) and `spectrum_peak_color`
(default the primary blue, `"none"`/`"off"` to draw none), resolved with
`spectrum_color` into one `SpectrumStyle` in place of the old
`set_spectrum_color`. Existing config files take the defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:08:43 +02:00
Test User 430c87451b tui: make the spectrum color configurable
The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane
borders, so the one moving thing on screen read as chrome.

`spectrum_color` in the client config now picks it, parsed by ratatui's
`Color: FromStr`: a `#rrggbb` triple, a color name, or a 0-255 palette index,
the latter two deferring to the terminal theme. It defaults to `COLOR_RED` —
the red the queue marks the playing track with — so the two agree on what
"now" looks like.

The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX`
for the config file, one being a `Color` and the other a string a user edits.
`the_default_spectrum_color_is_the_queue_red` stops them drifting.

A config file is user input, so an unparsable value warns on stderr and falls
back to the red instead of failing. It resolves in `run`, before the alternate
screen is entered, or the warning would be drawn over and lost.

Existing config files predate the key: they keep working and take the default,
since every field of the `ClapSerde` opt struct is optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:43:21 +02:00
Test User d2f687983e cbd-web: make the seek buttons monochrome type
``/`` (U+23EA/U+23E9) carry emoji presentation, so a browser with a color
emoji font drew them as pictures — taller than the line and off its baseline —
in a row that is otherwise monochrome type.

They are `«`/`»` now: plain text glyphs at the weight of the `‹` in the library
toolbar, and lighter than the prev/next marks either side, which suits a nudge
next to a skip. `font-variant-emoji: text` on the row asks for the text form of
prev/next/pause, which have the same presentation; it is ignored where
unsupported, at no cost, since those glyphs are the fallback anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:28:44 +02:00
Test User 43902fc818 cbd-web: drop the collapsed pane strip on phones
The strip was the unfocused pane's toolbar and the top of its list with the
rest clipped away — and clipped rows still take taps, so a tap aimed at the
truncated queue selected a queue entry, and one aimed at the library could
hit its "play" and replace the queue.

Now that the topbar tabs carry the switch, the strip has nothing left to
earn: below 700px only the focused pane is rendered. The pane's inline-end
border goes with it, since there is no second column to divide and
`:last-child` only spares the queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:25:49 +02:00
Test User 8e3c210864 cbd-web: give the panes a tab control
Below 700px the panes stack and only the focused one is open, but switching
was bound to `Tab` and to a tap on the collapsed strip — and a phone has no
`Tab` key. The top bar now carries `library` / `queue` buttons that set the
focus, shown at every width: on desktop they double as the readout of which
pane the keys go to, which the inset border says only faintly.

The collapsed strip is the pane's toolbar with all but the title clipped, so
its buttons were the only thing a tap on it could land on — tapping to switch
panes could hit the library's "play" and replace the queue. Hidden there now.

The connection line truncates rather than pushing the tabs off a narrow
screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:23:30 +02:00
Test User 1db88e0f5a cbd-web: end the volume slider where the server clamps
The slider was `max="1.5"` while the engine clamps to 1.1, so its right
third was unreachable — the fill stopped at 73% of the track and the rest
stayed empty however far the thumb was dragged.

`max` is now a named `MAX_VOLUME` that has to track
`PlayerEngine::set_volume`'s clamp. It cannot be imported: `audio-player`
is native-only and this client is wasm, so a comment on each end keeps
the pair honest. Raising the engine clamp was the alternative, but 1.1 is
deliberate headroom and more gain risks clipping, so the slider moved.

The tooltip now reports the level as a percentage too — the web
counterpart of the TUI's `Volume: 85%`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:18:20 +02:00
Test User 034737d14f server: report the real volume, mute and position
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>
2026-07-27 09:13:53 +02:00
Test User cc95e109fc tui: show the output level in the now-playing pane
The pane reports `Volume: 85%`, or `Volume: 85% (muted)`.

The display was the small half of this: the TUI was discarding the volume
it was already being sent (`StreamUpdate::Volume(_)` was a FIXME), and
`Init` dropped `volume` and `mute` as well — so the pane would have
started out wrong and corrected itself only once the user touched either
control. Both are wired now.

Muting keeps the level on screen rather than replacing it: the server
reports the level it would unmute to, which is the one the user is about
to adjust. That retires the old `, Muted` suffix.

The line now renders with no track loaded too — shuffle, repeat and
volume describe the server, and an idle player is exactly when you reach
for `K` blind.

Formatting is a pure function so its edges are tested: the wire carries a
float, so NaN and infinity read as `--` rather than `NaN%`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:07:32 +02:00
Test User 1e8afd5f41 plan: record the seek follow-up
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:29:47 +02:00
Test User 0d08c765a1 seek: put seek and track-skip on two keys, and make the web gauge clickable
Two physical keys now carry all four moves in both clients: `,`/`.` seek 15
seconds, and their shifted forms `<`/`>` skip a whole track. Self-teaching
(same key, shift = bigger jump), and `<`/`>` are the marks engraved on them.

The reason it is these keys and not control chords: they are plain printable
characters, so nothing a browser reserves can swallow them. Ctrl-n — the
long-standing next-track chord — cannot be claimed in a browser at all,
because Chrome and Firefox handle it as "new window" above the page where
preventDefault cannot reach (unlike Ctrl-f, Ctrl-p or Ctrl-b, which the
keydown handler does claim). So the web client had no working next-track key.
Ctrl-n/Ctrl-p stay bound as the terminal's primary chords; the web help
documents the pair that always works.

Click-to-seek on the web progress bar comes with it, and needed no new rpc:
the click maps the pointer's x within the gauge to a fraction of the duration
and sends target - position. Relative is right here even though the gesture
is absolute — the position it subtracts is the one drawn on the bar the user
just aimed at, at most one 250 ms tick old, far under one pixel of the bar.
That staleness is only fatal for a repeated key, which is why keys still send
a fixed step and let the server accumulate.

The arithmetic lives in state.rs as a pure function, so it is tested on the
native target rather than only in a browser: a click behind the playhead
seeks back, the edges are exactly the track's ends, a fraction outside [0, 1]
is clamped rather than extrapolated, and a duration of 0 (or a non-finite
fraction, meaning a zero-width element) declines instead of seeking somewhere
arbitrary. Geometry comes from current_target, since the click may land on
the fill rather than the track.

Also enables the web-sys DomRect feature, without which
Element::get_bounding_client_rect does not exist — caught only by the wasm
build, since cbd-web's `mod app` is cfg'd to wasm32 and native clippy never
sees it.

Verified: 119 cbd-tui, 24 cbd-web (3 new), workspace clippy clean under
-D warnings, fmt clean, wasm bundle and book build. Not exercised: an actual
click in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:29:30 +02:00
Test User 956a490606 rss: don't list image enclosures as episodes
Reported from a real subscription: an episode failed to play with "the
format of the data has not been recognized" on a cdn.netzpolitik.org/….jpg
URL, 272428 bytes — exactly the length the feed declared for a JPEG.

audio_url preferred an audio-typed enclosure but then fell back to *any*
media url, and a WordPress blog feed attaches each post's featured image as
an <enclosure>, structurally identical to a podcast enclosure apart from
type="image/jpeg". https://netzpolitik.org/feed/ is 25 items, 25 JPEG
enclosures, and no audio reference of any kind, so every post became an
episode that could not play.

An enclosure is now accepted when its type says audio, when the feed omits
the type (plenty of hand-rolled feeds do), or when a generic
application/octet-stream is backed by an audio file extension — and
rejected otherwise, so images and video are skipped. Audio-typed still
wins, so a show that publishes both plays as audio.

A feed with entries and no audio now says so ("this looks like a blog feed
rather than a podcast feed"): an empty listing explains nothing on its own.
No URL in the message — a feed URL is a credential.

Also raises DEFAULT_MAX_FEED_BYTES from 8 to 32 MiB. Logbuch:Netzpolitik,
559 episodes in, is a healthy 6.8 MiB: feeds carry their whole back
catalogue with full show notes, so the first cap would have started
refusing real feeds within a year or two. Still bounded, still enforced
while reading rather than after, still lowerable via max_feed_bytes.

Verified: 30 rssdy tests (5 new, over feed-rs's real entry shapes:
image-only, mixed image+audio, untyped, generic-with-extension,
generic-without, video), and both live feeds — netzpolitik.org/feed/ now
yields 0 episodes with the warning, logbuch-netzpolitik.de/feed/mp3
(-> feeds.metaebene.me/lnp/mp3) yields 559 audio/mpeg episodes whose
HH:MM:SS durations parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:16:25 +02:00
Test User d0b01c75d0 seek: move 15 seconds inside the playing track with Ctrl-b / Ctrl-f
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>
2026-07-26 14:16:08 +02:00
Test User 378066470e rss: subscribe to podcast feeds at /rss, including premium ones
A new rssdy crate mounted at /rss. Subscriptions are (name, url) pairs in
rss.toml; `%` on /rss takes a pasted feed URL, fetches it once, names the
subscription from the feed's own title and persists it, `e` renames, `d`
unsubscribes without touching captured audio. Feeds are read as RSS
2.0/1.0/0.x, Atom or JSON Feed through feed-rs.

**A premium feed URL is the credential.** Library paths are displayed,
logged, and persisted into saved queues and bookmark tomls, so a URL in one
leaks into all of them. Paths therefore carry a slug of the subscription name
plus blake3(guid)[..16] — /rss/the-economist-podcasts/676f8bfa48c9cac3 — and
URLs are redacted from every Debug impl and kept out of errors (reqwest goes
through without_url).

**Nothing is cached, at either end.** A listing always fetches. The half that
is easy to miss is client-side: both clients cache listings by path and only
/crabidy, /fs and /orphans bypassed it, so /rss joins MUTABLE_ROOTS in both —
otherwise a re-visit answers from the client and the server's freshness is
invisible. One memo, written by listings and read only when resolving a track
(bounded to 8 feeds), keeps queueing 40 episodes at one fetch instead of 41
without a TTL to guess at.

Verifying against the user's real Economist feed caught a bug no unit test
would have: feed-rs parses <itunes:duration> as NPT, which has no MM:SS form,
so "53:25" fell through to its leading-number regex and a 53-minute episode
reported 53 *seconds* ("1:20:40" happens to parse fine). That field is now
recovered from the raw body — a shallow scan keyed by guid and enclosure URL —
and the live feed reports 3205/2830/1662 s, matching 53:25/47:10/27:42.

Bounded by design: per-request timeout, an 8 MiB body cap enforced while
reading chunks rather than after the fact, an episode cap, newest-first
enforced at the provider boundary so any backend obeys it. A malformed entry
is skipped; only an unfetchable feed errors, and it fails that node alone.

Behind a default-on `rss` cargo feature like every other provider, with a row
in check-features. Documented in docs/src/providers/rss.md and
rssdy/README.md, both stating plainly that the URL is a credential, that
listings are never cached, and that bookmarks depend on publisher guids —
capture what you want to keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:30 +02:00
Test User bdc961501d architecture: design the /rss subscription provider
Podcast feeds you subscribe to by URL, including premium per-subscriber
URLs, with listings that are never cached.

Two findings shape the design. First, a premium feed URL *is* the
credential, and library paths are displayed, logged, and persisted into
saved queues and bookmarks — so the URL can never appear in one. Paths
therefore carry a slug of the subscription name plus a short blake3 hash of
the episode guid, and subscriptions live in rss.toml rather than being
addressed by URL.

Second, "not cached" has a client-side half: both clients cache library
listings by path and only /crabidy, /fs and /orphans bypass it. Without
adding /rss to those lists, a re-visit answers from the client's cache and
the server's freshness is invisible. A listing always fetches; a
listing-written memo (read only when resolving a track) keeps queueing 40
episodes to one fetch instead of 40, with no TTL to guess at.

Also picks feed-rs over hand-rolled parsing (RSS 2.0/1.0/0.x, Atom and JSON
Feed in one maintained crate — real podcast feeds are not uniform), and
records the risks that cannot be engineered away: publishers who
regenerate guids break bookmarks, and episodes ageing out of a feed cannot
be resolved. Capture rather than bookmark what you want to keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:19:23 +02:00
Test User 1b2f01a2b6 queue: make the Insert *command* mean what the primitive now means
The previous commit fixed QueueManager::insert_tracks to insert at an index
but left the paste path unchanged, so pasting behaved exactly as before.
PlaybackCommand::Insert built a ResolveKind::InsertAfter(position), and that
op compensated with a + 1 — which cancelled the fix on the one path paste
actually takes. Every test I had written drove either the primitive or the
op directly, so nothing caught it.

ResolveKind::InsertAfter is now InsertAt: the index insert_tracks takes, no
offset. The Insert command passes its position straight through, and the two
callers that mean "after" add the 1 themselves — play-next (`L`) passes
current + 1, and the op's chunk arithmetic is unchanged.

Three tests now cover the level that was missing: the Insert command at an
interior index pushes the row that was there down, the Insert command at 0
reaches the front (what `P` on the first row needs), and play-next still
lands right after the current track rather than on top of it. All three
would have failed before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:10:31 +02:00
Test User 7fe4326923 queue: insert at a position, not after it, and show queue marks
Two bugs from the register work, both reported from actual use.

**Paste landed one row too far.** `QueueManager::insert_tracks` spliced at
`position + 1` — it inserted *after* the given index, while its own CLI help
says "insert tracks/subtrees at a position". So `p` (which sent cursor + 1)
landed two below the cursor and `P` (cursor) landed one below, exactly as
reported. The clients were already computing the right indices for an
insert-*at* API.

Fixed at the primitive rather than in the clients, because "after" cannot
express the front of the queue: the earliest reachable index was 1, so
pasting before the first row — and therefore undoing a delete of it — was
impossible. `insert_tracks` now inserts **at** `position`, pushing that row
down, with 0 the front and past-the-end an append. The two callers that
genuinely mean "after" pass `N + 1`: `ResolveKind::InsertAfter` (which keeps
its name and its streaming-chunk arithmetic) and `queue_tracks` (play-next,
`L`). All existing behaviour is preserved — the whole server suite passes
untouched — and three tests pin the new front/interior/play-next cases.

`queue insert <POS>` on the CLI shifts by one accordingly, which brings it
in line with what its help always claimed. Documented in the proto, the CLI
help, and the book.

**Queue marks were invisible.** The TUI rendered no mark indicator, so `s`
and visual mode had no feedback. Marked rows now carry the library's `*`
prefix and the same green bold; the playing row keeps `>` and its red, and a
row that is both shows `> * title`. The web client already rendered marks
(its `.marked .title` rule), but neither client showed visual mode outside
the TUI's pane title — both panes there now get a VISUAL badge in the
toolbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:04:03 +02:00
Test User 95700bf31f cbd-web: queue marks, visual mode, and the register
Brings the web client level with the TUI in the same change rather than
deferring parity again — which also closes the library visual mode that was
left out when v/V landed in the terminal.

The web queue owns no list (just a cursor, with rows rendered straight from
the server signal), so its marks live on QueueCursor beside the snapshot:
mark flags plus the paths they were taken against, carried across each
update by the same greedy in-order path match the TUI uses. Same rule, same
seven reconciliation cases tested here too, so the two clients cannot drift.

Adds s / v / V / y / p / P in the queue, v / V / y in the library, marks
rendered on queue rows, register count in the queue toolbar, and the visual
auto-leave rule. The "insert" button became "paste". Library movement now
paints in visual mode — a dead-code warning on the wasm target was what
caught that it did not.

Docs: the TUI page gains a register section (what it is, and that it is per
client, one slot, and re-resolves paths on paste), the web page points at
it, queue.md explains why Remove takes positions and Insert takes paths,
and the README walkthrough covers the keys. All of them state plainly that
p changed meaning.

Verified on both targets: cbd-web clippy is clean for native *and*
wasm32-unknown-unknown (mod app only compiles for wasm, so native alone
proves nothing), 20 tests pass, and the trunk bundle builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:35:21 +02:00
Test User ad19b6352e cbd-tui: queue marks, visual mode, and a paste register
The queue could only delete the row under the cursor, one at a time
(queue.rs carried the FIXME asking for exactly this), and nothing in the
system had an undo. Both gaps close with one vim-shaped concept.

Marks and visual mode move into a MarkedPane trait in list.rs, next to the
existing StatefulList, and both panes implement it — the library keeps its
behaviour verbatim (its whole visual-mode suite passes untouched), the
queue gains it with an always-allowed mark gate, since its rows carry
is_queable: false.

The register is one unnamed in-memory slot holding library paths, written
only by y (both panes) and d/c/C in the queue, and read by p (insert after
the cursor) and P (before it). So d then P restores exactly what you
deleted, d … p is a move, and clearing 200 tracks with C is finally
recoverable. Paste leaves the register intact and an empty register pastes
nothing rather than sending an empty Insert.

No server work: Remove already accepted many positions and Insert already
took a path list. Because the register holds paths, paste re-resolves — a
yanked album node expands at paste time, and a path that no longer resolves
does not come back.

The one genuinely hard part is that queue marks are positional while the
queue is server-pushed and rebuilt on every change. carry_marks() carries
marks across a snapshot by a greedy in-order match on track path, so a mark
follows its own track through appends, removals, and playback advancing
instead of silently retargeting; irreconcilable snapshots clear rather than
guess. Positions handed to Remove are always read off the newest list.

Breaking change: p in the queue pastes the register instead of inserting
the library selection. That flow is now y then p; a/L/Enter are untouched.
Library queue_insert() had no other caller and is gone.

Also rebalances the help modal's columns (Global + Queue left, Library
right). It was already overflowing at 46 rows in one column; the new
bindings made that worse. It still truncates below ~43 rows — pinned by a
test rather than hidden, and scrolling remains the real fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:24:51 +02:00
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