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>
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.
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>
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>
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>
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>
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>
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>
Pressing / in either pane opens a live case-insensitive substring
filter: typing narrows the visible rows, Enter keeps the filter and
returns to navigation, Esc clears it. A shared Filter helper keeps each
pane full list intact and maps view indices to real ones, so movement
keys work on the filtered view unchanged and the queue maps a filtered
selection back to the real server position before removing or setting
current. The library resets search on node change; the queue preserves
it across stream updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
crabidy-server.toml gains an [auth] section with one argon2 PHC hash
per role: owner (everything), queue-owner (queue and playback, no
library writes), queue-appender (browse, search, and Append only).
Enforcement is a single fail-closed tower layer in front of the tonic
service — unknown methods require owner, a malformed config aborts
startup, and a missing one keeps the server open as before. Successful
credentials are cached so argon2 runs once, failures re-verify at full
cost and stay indistinguishable. crabidy-server hash-password turns a
stdin password into the config hash; cbd-tui sends the header from new
user/password options.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deletion (d) previously reached only top-level folders of editable
stores. /captures now exposes its whole tree: nested folders delete
recursively, single tracks delete their metadata file plus the
downloaded audio next to it (never audio outside the instance root).
Tracks advertise this through the new LibraryNode.tracks_deletable
flag. Because these deletes destroy slow-to-redo downloads, the TUI
asks delete <title>? [y/N] first; cheap deletables (search terms,
bookmarks, saved queues) stay unconfirmed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Download captures now write straight into captures/<name>: satisfied
entries are reused, uncapturable tracks are recorded as skipped tomls
(a new fourth playable, marked red in the TUI and skipped by playback
with a bounded pass), and a failed run keeps its progress so capturing
the same name resumes it. The capture RPC replies on acceptance and
streams CaptureProgress over the update stream, rendered as status
lines in the library pane; help and the input overlay warn that
captures are slow. Colored list items switch to a dark foreground
under the focused selection bar so they stay readable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crabidy-server and cbd-tui become libraries with thin mains:
crabidy_server::serve(addr) hosts the whole server stack,
cbd_tui::run(config) the client loops. The new cbd binary logs both
halves to one file, starts the server in-process, waits for the socket
(adopting an already-running standalone server on an occupied port),
and runs the TUI against it over the unchanged localhost gRPC wire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>