Compare commits

..

3 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
33 changed files with 3384 additions and 79 deletions

1
Cargo.lock generated
View File

@ -706,6 +706,7 @@ dependencies = [
"crossterm", "crossterm",
"dirs", "dirs",
"flume", "flume",
"libc",
"mpris-server", "mpris-server",
"notify-rust", "notify-rust",
"ratatui", "ratatui",

View File

@ -49,6 +49,9 @@ futures = "0.3"
gloo-timers = { version = "0.3", features = ["futures"] } gloo-timers = { version = "0.3", features = ["futures"] }
http = "1" http = "1"
include_dir = "0.7" include_dir = "0.7"
# `dup2` only: the TUI points stderr at a file so C-library writes (ALSA
# underrun messages) cannot scribble on the interface (cbd-tui/src/stderr.rs).
libc = "0.2"
leptos = { version = "0.8", default-features = false, features = ["csr"] } leptos = { version = "0.8", default-features = false, features = ["csr"] }
notify-rust = "4" notify-rust = "4"
# The MPRIS D-Bus surface of cbd-tui. Built on zbus, which speaks the # The MPRIS D-Bus surface of cbd-tui. Built on zbus, which speaks the

356
architecture/queue-order.md Normal file
View File

@ -0,0 +1,356 @@
# Queue order: de-duplication and sorting
## Context
The queue is an ordered list of tracks the server owns; clients mutate it
through RPCs and learn the result from the update stream
(`docs/src/queue.md`). Every existing mutation either *adds* tracks
(`Replace`/`Append`/`Queue`/`Insert`), *drops* them (`Remove`/`ClearQueue`),
or moves the cursor (`SetCurrent`). Nothing **reorders** what is already
there, and nothing notices that the same track is in the queue twice.
Both gaps show up in ordinary use:
- Queue an artist, then a playlist that contains three of the same tracks,
and the queue plays them twice. Today the only fix is spotting the rows
and pressing `d` on each.
- Queue five albums by appending them, and they play in the order they were
appended, interleaved by however the provider listed them. There is no way
to say "group this by artist" or "shortest first" short of clearing the
queue and re-queueing in a different order.
So: two new queue-order operations — **dedup** (drop duplicate entries) and
**sort** (reorder by a strategy) — for every client, without re-resolving
anything through providers.
## Assumptions
- **A1 — The server owns queue state; clients ask and then observe.** Same
rule as `architecture/seek.md` A2 and `architecture/mpris.md` A2: a client
sends the operation, the playback loop performs it, and the resulting
`Queue` snapshot is the truth. No client predicts the new order.
- **A2 — Order is queue state, shuffle is a modifier.** `tracks` is the
queue order (what every client renders, and what playback follows with
shuffle off); `play_order` is the *play* order shuffle permutes. Dedup and
sort rewrite the former. They are not new modifiers: nothing about them is
remembered, they are one-shot rewrites, and after one runs the queue is
just a queue in a different order.
- **A3 — Both work on metadata already in the queue.** `Track` carries
path, artist, title, album, duration and `provider_item_id`; no provider
round trip, no network, no new provider trait method.
- **A4 — The current track keeps playing.** Neither operation interrupts
audio: dedup never removes the playing track, sort never restarts it. The
playing track's *position* changes, which the existing `QueueTrack`
broadcast already covers.
- **A5 — A queue can be long.** Ten thousand entries after queueing a large
artist is normal (`architecture/progressive-queueing.md`), so both
operations must be O(n log n) with keys computed once per track, not per
comparison, and must not hold the queue lock across an await.
## Options considered
### Where the operations run
**Option A — client-side, on top of the existing RPCs.** A client already
holds the full `Queue` snapshot, so it could find duplicate positions itself
and send `Remove`. Sorting would mean sending `Replace` with the paths in the
new order — which re-resolves every path through its provider (slow, and a
provider that has since changed its listing returns something else), loses
the playing track (a replace restarts playback at the head), and would make
each client reimplement the comparators. And the `Remove` positions are
computed from a snapshot that a still-running resolve can invalidate before
the request lands.
**Option B — server-side RPCs *(chosen)*.** Both run on the playback loop,
the single writer of queue state: atomic against resolve chunks and against
other clients, no re-resolution, the current track is identified by *index*
and not by re-lookup, and the result reaches every client (and the persister)
through the one broadcast site that already exists.
**Option C — a general `ReorderQueue(permutation)` RPC.** The client decides
the order and the server applies it; sort strategies would then be a purely
client-side concern, and the same RPC would later serve drag-and-drop and
"move this track up". More general, but a permutation is an argument about
*positions*, and positions shift under a running resolve — a stale
permutation is not merely a no-op, it scrambles the queue. It also puts five
comparators in every client. Kept as **Deferred**: a move/reorder RPC wants
a different argument shape (identify rows by path, not index) and is its own
feature.
**Decision: Option B.** Two RPCs, `DedupQueue` and `SortQueue`, handled on
the playback loop like every other queue verb.
### What counts as a duplicate
1. **Same `path`.** Exactly the same library entry queued twice.
2. **Same provider item**`provider_item_id` scoped to its provider, with
`path` as the fallback when the id is empty. Catches the same track
reached by two routes: through an album, through a playlist, through a
search result.
3. **Same artist and title**, normalized. Would also catch the same song
from two different providers.
**Decision: 2 by default, falling back to 1** (D3) — and **3 behind an
explicit flag** (D3a). 3 cannot be the default: identical artist/title is
routinely a *different recording* — a live take, a remaster, a radio edit, the
studio version — and the server cannot tell which, so a default that merged
them would silently and unrecoverably edit the queue, with the false positives
landing exactly on the collections (greatest-hits, live albums, remix albums)
where a user is most deliberate. But it is what a listener sometimes means, so
it is reachable in one keypress and never by accident.
## Decisions
- **D1 — Two RPCs, both `QueueOwner`.** `DedupQueue` and `SortQueue` mutate
the queue, so they sit with `Remove`/`ClearQueue`/`SetCurrent` in the
rights matrix (`architecture/roles-auth.md`), not with the one appender
verb. The pinned method-list test in `auth.rs` keeps a new RPC from
reaching the wire unmapped.
- **D2 — `DedupQueue` answers with a count.** Every other queue verb
answers with an empty message because the update stream carries the truth.
Dedup is the exception: "how many did that remove?" cannot be recovered
from the new snapshot (a client would have to diff against a snapshot it
may never have had), and `0` is the answer a user most needs — it says
*there were no duplicates*, as opposed to *nothing happened*. So
`DedupQueueResponse.removed`, produced on the loop and returned through a
bounded result channel, the way `SaveQueue` already reports.
`SortQueueResponse` stays empty: the new order *is* the answer, and it
arrives on the stream.
- **D3 — The default duplicate identity is `(provider, provider_item_id)`, or
the whole path.** The provider is the first path segment; ids are provider-internal
(the content store keys them the same way, `by_provider_id(provider, id)`),
so leaving them unscoped would let two providers' numeric ids collide and
merge two unrelated tracks. When the id is empty — most providers, and
every local file — the key is the full path, which is exact. The
consequence worth stating: a captured copy under `/crabidy` and its
streaming original are **not** duplicates, because they are different
providers. Conservative on purpose: a missed duplicate is a keypress, a
wrong merge is lost queue state.
- **D3a — …and an opt-in `by_title` identity beside it.** Measured against a
real queue, D3 alone removed nothing from 121 entries — every entry was a
distinct provider item — while the *user* saw duplicates: four "Referees
Don't Fall In Love", three "Sink Into The Hips". They were remixes, edits and
album versions of the same songs, which D3 is right to keep and which a
listener may still not want three of. So `DedupQueue` takes a flag: with
`by_title` the identity is the lowercased `(artist, title)` pair, and the
survivor of a group is the **longest** take (the full version rather than a
radio edit), still yielding to the playing entry. It stays opt-in and on its
own key, because it *discards recordings that differ* — the exact loss D3
refuses to make the default. Duration-tolerant matching was the third option
and is not worth its complexity: on the queue that motivated this, every
same-title group differed by tens of seconds, so any tolerance narrow enough
to be safe caught nothing.
- **D4 — The survivor is the current track, else the earliest.** Within a
group of duplicates, the entry at the current position survives if it is
in the group; otherwise the earliest one does, and every later copy goes.
"Keep the first" alone would remove the playing track whenever the playing
copy was a later one — a dedup that stops the music is a bug, not a
policy (A4).
- **D5 — Dedup is expressed as a removal.** It computes the positions to
drop and hands them to `QueueManager::remove_tracks`, which already
maintains `play_order`, shifts `current_offset`, and ignores out-of-range
positions. Since the current track is never in that list, `remove_tracks`
reports no successor to start and playback is untouched — the same code
path a client's `d` takes, so there is one removal implementation, not two.
- **D6 — Five sort strategies, on a proto enum.** `ARTIST`, `ALBUM`,
`TITLE`, `DURATION` and `REVERSE`, plus a `descending` flag.
`UNSPECIFIED` (the proto3 default, i.e. a client that forgot the field) is
`InvalidArgument`, never a silent default. `REVERSE` reverses the order
the queue is in and ignores `descending` — it is not a key, and reversing
descendingly is the same thing.
- **D7 — The sort is stable, and the keys are compound.** `ARTIST` sorts by
(artist, album) and `ALBUM` by (album) alone; within an equal key, queue
order survives — which for a queued album *is* its track order, and is
much closer to what a user means than sorting an album's tracks
alphabetically by title. A stable sort is what makes "sort by album, then
by artist" compose across two presses, too.
- **D8 — Unknown sorts last, in both directions.** An empty artist/album/
title and an absent duration go to the end ascending *and* descending. A
length-less web radio stream is not "the longest track", and a missing
album is not alphabetically first. One rule for every key, so a user never
has to remember which end the blanks pile up at.
- **D9 — Text comparison is case-insensitive and locale-naive.** Keys are
lowercased once per track (decoratesortundecorate, A5), then compared as
Unicode strings. No collation, no article stripping: "The Beatles" sorts
under T. Locale-aware collation would need a collation library and a
locale the server does not have (it has no user, only clients); doing it
half-way — special-casing English articles — would be wrong for every
other language in a music library.
- **D10 — Sorting reorders `tracks`; what plays next depends on shuffle.**
With shuffle **off**, `play_order` is rebuilt as the identity over the new
order and `current_offset` follows the current track: the queue order *is*
the play order, so a sort changes what plays next — the point of sorting.
With shuffle **on**, `play_order` is remapped through the sort permutation,
so the shuffled play sequence and the position within it are preserved
exactly: the user asked for a random order, and sorting the *display* must
not silently reshuffle. Either way the current track stays current and
keeps playing (A4).
- **D11 — Both are legal while a resolve is in flight, and neither waits for
it.** They apply to what the queue holds at that moment; chunks still
being resolved land afterwards at their insert index or at the end,
unsorted. Refusing (`FailedPrecondition`) would make both operations
flaky exactly on the big queues that need them, and waiting would block
the loop. Clients already render the `resolving` indicator, so "more is
still arriving" is visible; a second press settles the rest.
- **D12 — Both persist through the existing broadcast site.**
`broadcast_queue` hands the snapshot to the persister and to the stream,
so a dedup or a sort survives a server restart with no new persistence
code (`architecture/queue-persistence.md`).
- **D13 — Pure key logic lives in its own module.** `queue_order.rs` holds
the duplicate key, the sort keys, and the permutation — no state, no locks,
unit-testable directly. `QueueManager::dedup`/`sort` keep the
`play_order`/`current_offset` bookkeeping, because that invariant is
theirs. The split is what keeps D3/D7/D8 testable as data instead of
through a queue.
- **D14 — TUI: `u` dedups, `U` dedups by title, `S` opens a sort menu.** `u`
is "unique" and is free in the queue scope; the shifted form is the shifted
*behaviour* (D3a), which is the pattern `w`/`W` and `c`/`C` already use here
for "the same verb, more of it". Sorting needs a choice, so `S` opens a modal
overlay listing the five strategies with their letters (`a` artist, `l`
album, `t` title, `d` duration, `r` reverse; the capital of each sorts
descending), `Esc` closes. A menu rather than a chord sequence: the
strategies are discoverable in the overlay instead of only in the help
modal, and the app already has three modal overlays (help, input, search)
to follow. It is modal in the same strict sense — while it is open, the
bindings table is unreachable.
- **D15 — The dedup count is reported in the queue pane's title, briefly.**
`Queue — removed 7 duplicates`, for a few seconds, then back to normal.
The title already multiplexes VISUAL, the `/` query and the register count,
so no layout changes and no new region; and the message is queue-scoped,
which is where the user is looking after pressing `u`. The count travels
as a typed `MessageToUi` variant, not a preformatted string — the wording
is the UI layer's business.
- **D16 — The web client keeps the same keys and adds the two buttons a mouse
needs.** `u` dedups and `S` opens the sort menu as a dialog, because the
keymap is deliberately the TUI's (`architecture/web-client.md`); the menu's
rows are clickable as well as typeable. The queue toolbar gains a **sort**
button that opens that same dialog and a **dedup** button — without them
both operations would be invisible to a mouse, and routing the button
through the same menu keeps the strategy list in exactly one place (a
second widget listing five strategies is a second thing to keep in step).
The dedup count goes to the existing toast rather than a pane title.
- **D17 — CLI: `cbd queue dedup` and `cbd queue sort <key> [--desc]`.** The
strategy is a `clap` `ValueEnum`, so the shell completes it and a typo is
a parse error rather than an `InvalidArgument` round trip. `dedup` prints
the count it got back (D2).
## Structure
```d2
direction: right
clients: Clients {
tui: cbd-tui\nu / S menu
web: cbd-web\nu / S / select
cli: cbd queue\ndedup / sort
}
rpc: gRPC (QueueOwner) {
dedup: DedupQueue\n-> removed: u32
sort: SortQueue\n(strategy, descending)
}
loop: playback loop\n(single writer) {
cmd: PlaybackCommand\nDedupQueue / SortQueue
qm: QueueManager\ntracks + play_order
keys: queue_order\nduplicate key, sort keys
}
out: One broadcast site {
stream: Queue update\n(every client)
persist: persister\n(latest wins)
}
clients.tui -> rpc.dedup
clients.web -> rpc.dedup
clients.cli -> rpc.dedup
clients.cli -> rpc.sort
clients.tui -> rpc.sort
clients.web -> rpc.sort
rpc.dedup -> loop.cmd
rpc.sort -> loop.cmd
loop.cmd -> loop.qm: mutate
loop.qm -> loop.keys: keys / permutation
loop.qm -> out.stream
loop.qm -> out.persist
rpc.dedup -> clients.cli: removed count
```
The sort's effect on the two orders (D10) — the same permutation, applied
differently depending on shuffle:
```d2
shape: sequence_diagram
client: client
loop: playback loop
off: QueueManager\nshuffle off
on: QueueManager\nshuffle on
stream: update stream
client -> loop: SortQueue(ARTIST, asc)
loop -> off: sort(ARTIST, asc)
off -> off: tracks := sorted
off -> off: play_order := identity
off -> off: offset := the current track's new index
loop -> on: sort(ARTIST, asc)
on -> on: tracks := sorted
on -> on: play_order := remapped through the permutation
on -> on: offset unchanged
off -> stream: Queue (new order, new position)
on -> stream: Queue (new order, new position)
loop -> client: SortQueueResponse {}
```
## Boundaries
- **`crabidy-server::queue_order`** — pure functions: the duplicate key of a
track, the sort key of a track, and the permutation for a strategy. Knows
nothing about locks, play order or clients.
- **`crabidy-server::QueueManager`** — gains `dedup()` and `sort()`. Owns
the `play_order`/`current_offset` invariants; `dedup` delegates the actual
removal to `remove_tracks` (D5).
- **`crabidy-server::playback`** — two new `PlaybackCommand` arms, each a
lockmutatebroadcast on the loop. `DedupQueue` carries a result channel
for the count.
- **`crabidy-server::rpc` / `auth`** — the two methods, their argument
validation (`UNSPECIFIED` → `InvalidArgument`), and their row in the rights
matrix.
- **Clients** (`cbd-tui`, `cbd-web`, `cbd-cli`) — bindings/commands, the
sort-menu modal, and rendering the count. No client computes an order.
- **Unchanged**: providers, the content store, the resolve pipeline, and
every existing queue RPC.
## Risks
- **A pending insert's index goes stale.** Dedup shifts positions and sort
moves everything, so the remaining chunks of an in-flight `InsertAt` op
land somewhere else than the user pointed at. This is pre-existing — a
plain `Remove` while resolving does the same — and bounded by D11's
"settle it with a second press", but it is real.
- **Dedup is per-provider (D3), so the obvious cross-provider duplicate — a
captured track and its streaming source — stays.** It is the conservative
end of a trade-off, and it will read as a bug to someone.
- **A sort with shuffle on changes nothing audible** (D10) and may look
broken. The clients show shuffle state, and the queue visibly reorders.
- **Very large queues copy their tracks once per sort.** A `Vec<Track>`
permutation on ten thousand tracks is a handful of milliseconds on the
loop; well under the loop's other work (a resolve chunk), but it is work
done while no other command is served.
## Deferred
- **A preview for `by_title`** (D3a): a confirmation view listing which
recording of each song would survive, so the aggressive identity can be
inspected before it removes anything rather than only undone by re-queueing.
- **`ReorderQueue`/move**: drag-and-drop in the web client and `K`/`J` row
moves in the TUI, on an RPC that identifies rows by path rather than
index (Option C).
- **Sort by release year.** `Album.release_date` is a provider string and
not always ISO 8601, so a year needs the same parse-or-drop treatment the
TUI notification does; worth doing once that parse lives somewhere shared.
- **A remembered sort** ("keep the queue sorted by artist as tracks
arrive") — a modifier, which is a different feature from a one-shot
rewrite (A2), and one that fights progressive queueing.
- **Sort within a marked range only**, the visual-mode analogue of a partial
sort.

View File

@ -17,14 +17,15 @@ use tonic::{Request, Status};
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode,
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, NextRequest, PrevRequest, Queue, QueueSort, RemoveRequest, RenameLibraryNodeRequest,
SaveQueueRequest, SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest,
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, Track, SortQueueRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
ToggleShuffleRequest, Track,
}; };
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd}; use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd, SortKey};
/// The reserved library path the live queue mirrors into; `queue capture` /// The reserved library path the live queue mirrors into; `queue capture`
/// captures it (architecture/crabidy-store.md). /// captures it (architecture/crabidy-store.md).
@ -167,6 +168,29 @@ async fn run_library(
Ok(()) Ok(())
} }
/// The wire strategy a CLI `sort <key>` names (architecture/queue-order.md D17).
fn wire_sort(key: SortKey) -> QueueSort {
match key {
SortKey::Artist => QueueSort::Artist,
SortKey::Album => QueueSort::Album,
SortKey::Title => QueueSort::Title,
SortKey::Duration => QueueSort::Duration,
SortKey::Reverse => QueueSort::Reverse,
}
}
/// The CLI spelling of a strategy, so the confirmation line names it the way
/// the user typed it.
fn sort_label(key: SortKey) -> &'static str {
match key {
SortKey::Artist => "artist",
SortKey::Album => "album",
SortKey::Title => "title",
SortKey::Duration => "duration",
SortKey::Reverse => "reverse",
}
}
async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> { async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> {
match cmd { match cmd {
QueueCmd::Show => { QueueCmd::Show => {
@ -217,6 +241,34 @@ async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std
.map_err(rpc_error)?; .map_err(rpc_error)?;
println!("cleared the queue"); println!("cleared the queue");
} }
QueueCmd::Dedup { titles } => {
let response = client
.dedup_queue(DedupQueueRequest { by_title: titles })
.await
.map_err(rpc_error)?;
// 0 is a real answer — "there were no duplicates" — so it is
// printed like any other count (architecture/queue-order.md D2).
let removed = response.into_inner().removed;
if titles {
println!("removed {removed} same-title duplicate(s)");
} else {
println!("removed {removed} duplicate(s)");
}
}
QueueCmd::Sort { key, desc } => {
client
.sort_queue(SortQueueRequest {
sort: wire_sort(key) as i32,
descending: desc,
})
.await
.map_err(rpc_error)?;
match key {
SortKey::Reverse => println!("reversed the queue"),
_ if desc => println!("sorted the queue by {} (descending)", sort_label(key)),
_ => println!("sorted the queue by {}", sort_label(key)),
}
}
QueueCmd::SetCurrent { position } => { QueueCmd::SetCurrent { position } => {
client client
.set_current(SetCurrentRequest { position }) .set_current(SetCurrentRequest { position })

View File

@ -70,6 +70,23 @@ pub enum LibraryCmd {
} }
/// Queue operations against a running server. /// Queue operations against a running server.
/// How `queue sort` orders the queue — the CLI spelling of the wire's
/// `QueueSort` (architecture/queue-order.md D17). A `ValueEnum` so the shell
/// completes it and a typo is a parse error instead of a round trip.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum SortKey {
/// Artist, then album; queue order within an album.
Artist,
/// Album title.
Album,
/// Track title.
Title,
/// Duration; tracks with no known length sort last.
Duration,
/// Reverse the order the queue is in now.
Reverse,
}
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum QueueCmd { pub enum QueueCmd {
/// Print the current queue. /// Print the current queue.
@ -88,6 +105,24 @@ pub enum QueueCmd {
#[arg(long)] #[arg(long)]
keep_current: bool, keep_current: bool,
}, },
/// Drop duplicate entries, keeping one copy of each track. Prints how
/// many were removed; never removes the playing track.
Dedup {
/// Collapse same artist+title instead of the provably-same item: one
/// entry per *song*, keeping the longest take. Aggressive — a remix and
/// the album version count as the same song.
#[arg(long)]
titles: bool,
},
/// Reorder the queue by one strategy (architecture/queue-order.md).
Sort {
#[arg(value_enum)]
key: SortKey,
/// Largest/last first. Ignored by `reverse`; blanks and unknown
/// durations sort last either way.
#[arg(long)]
desc: bool,
},
/// Jump to a queue position. /// Jump to a queue position.
SetCurrent { position: u32 }, SetCurrent { position: u32 },
/// Link-save the current queue as `/crabidy/<name>`. /// Link-save the current queue as `/crabidy/<name>`.
@ -401,6 +436,54 @@ mod tests {
} }
} }
#[test]
fn queue_dedup_takes_an_optional_titles_flag() {
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "dedup"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Dedup { titles })) => {
assert!(!titles, "the safe identity unless asked")
}
other => panic!("unexpected: {other:?}"),
}
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "dedup", "--titles"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Dedup { titles })) => assert!(titles),
other => panic!("unexpected: {other:?}"),
}
}
/// The strategy is a value enum, so it is spelled lowercase, completes in
/// a shell, and a typo fails at parse time rather than as a server
/// `InvalidArgument` (architecture/queue-order.md D17).
#[test]
fn queue_sort_parses_a_strategy_and_an_optional_direction() {
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "artist"]).expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Sort { key, desc })) => {
assert_eq!(key, SortKey::Artist);
assert!(!desc, "ascending unless asked");
}
other => panic!("unexpected: {other:?}"),
}
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "duration", "--desc"])
.expect("parse");
match cli.command {
Some(TuiCommand::Queue(QueueCmd::Sort { key, desc })) => {
assert_eq!(key, SortKey::Duration);
assert!(desc);
}
other => panic!("unexpected: {other:?}"),
}
assert!(
TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "artiste"]).is_err(),
"a misspelled strategy must not reach the server"
);
assert!(
TuiCli::try_parse_from(["cbd-tui", "queue", "sort"]).is_err(),
"the strategy is required"
);
}
#[test] #[test]
fn global_volume_parses_a_signed_delta() { fn global_volume_parses_a_signed_delta() {
let cli = let cli =

View File

@ -24,6 +24,7 @@ clap.workspace = true
dirs.workspace = true dirs.workspace = true
toml.workspace = true toml.workspace = true
flume.workspace = true flume.workspace = true
libc.workspace = true
mpris-server = { workspace = true, optional = true } mpris-server = { workspace = true, optional = true }
notify-rust = { workspace = true, optional = true } notify-rust = { workspace = true, optional = true }
ratatui.workspace = true ratatui.workspace = true

View File

@ -122,6 +122,19 @@ pub enum Action {
QueueRemoveTrack, QueueRemoveTrack,
QueueClearKeepCurrent, QueueClearKeepCurrent,
QueueClearAll, QueueClearAll,
/// Drop duplicate entries from the queue, server-side. The playing track
/// is never the one removed, so it cannot interrupt playback; the number
/// removed appears in the pane title for a few seconds
/// (architecture/queue-order.md).
QueueDedup,
/// Drop entries with the same artist and title, keeping the longest take —
/// one entry per *song* rather than per recording. Aggressive and opt-in:
/// it discards remixes and edits (architecture/queue-order.md D3a).
QueueDedupTitles,
/// Open the sort menu: one keypress there reorders the whole queue
/// (architecture/queue-order.md D14). Modal — while it is open, this
/// table is unreachable.
QueueSortMenu,
/// Open the input overlay asking for a name to save the queue under /// Open the input overlay asking for a name to save the queue under
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty. /// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
QueueSaveAs, QueueSaveAs,
@ -552,6 +565,27 @@ pub const BINDINGS: &[Binding] = &[
action: Action::QueueClearAll, action: Action::QueueClearAll,
description: "Clear entire queue", description: "Clear entire queue",
}, },
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
code: KeyCode::Char('u'),
action: Action::QueueDedup,
description: "Unique: drop duplicate tracks from the queue",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('U'),
action: Action::QueueDedupTitles,
description: "Unique by title: one entry per song, keeping the longest",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('S'),
action: Action::QueueSortMenu,
description: "Sort the queue (opens a menu of strategies)",
},
Binding { Binding {
scope: Scope::Queue, scope: Scope::Queue,
mods: KeyModifiers::NONE, mods: KeyModifiers::NONE,
@ -1056,6 +1090,53 @@ mod tests {
} }
} }
/// The queue-order keys are queue-scoped: `u` must not shadow anything in
/// the library, and `Ctrl-u` (jump up) must stay itself
/// (architecture/queue-order.md D14).
#[test]
fn queue_order_keys_are_bound_in_the_queue_only() {
assert_eq!(
lookup(
UiFocus::Queue,
false,
key(KeyCode::Char('u'), KeyModifiers::NONE)
),
Some(Action::QueueDedup)
);
assert_eq!(
lookup(
UiFocus::Queue,
false,
key(KeyCode::Char('S'), KeyModifiers::SHIFT)
),
Some(Action::QueueSortMenu)
);
assert_eq!(
lookup(
UiFocus::Queue,
false,
key(KeyCode::Char('U'), KeyModifiers::SHIFT)
),
Some(Action::QueueDedupTitles)
);
for code in [KeyCode::Char('u'), KeyCode::Char('U'), KeyCode::Char('S')] {
assert_eq!(
lookup(UiFocus::Library, false, key(code, KeyModifiers::NONE)),
None,
"{code:?} belongs to the queue"
);
}
assert_eq!(
lookup(
UiFocus::Queue,
false,
key(KeyCode::Char('u'), KeyModifiers::CONTROL)
),
Some(Action::QueueJumpUp),
"Ctrl-u still jumps"
);
}
#[test] #[test]
fn key_labels_are_human_readable() { fn key_labels_are_human_readable() {
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q"); assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q");

View File

@ -5,6 +5,7 @@ mod list;
mod now_playing; mod now_playing;
mod queue; mod queue;
mod register; mod register;
mod sort;
use flume::Sender; use flume::Sender;
use ratatui::{ use ratatui::{
@ -16,7 +17,7 @@ use ratatui::{
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
get_update_stream_response::Update as StreamUpdate, CaptureProgress, get_update_stream_response::Update as StreamUpdate, CaptureProgress,
InitResponse as InitialData, LibraryNode, InitResponse as InitialData, LibraryNode, QueueSort,
}; };
pub(crate) use list::{carry_marks, MarkedPane}; pub(crate) use list::{carry_marks, MarkedPane};
@ -106,6 +107,12 @@ pub enum MessageToUi {
Init(InitialData), Init(InitialData),
ReplaceLibraryNode(LibraryNode), ReplaceLibraryNode(LibraryNode),
Update(StreamUpdate), Update(StreamUpdate),
/// A `DedupQueue` finished and removed this many entries — the count the
/// queue pane shows for a few seconds (architecture/queue-order.md D15).
/// Typed rather than a preformatted string: the wording belongs to the UI.
QueueDeduped {
removed: u32,
},
} }
// FIXME: Rename this // FIXME: Rename this
@ -147,6 +154,18 @@ pub enum MessageFromUi {
RemoveTracks(Vec<usize>), RemoveTracks(Vec<usize>),
ReplaceQueue(Vec<String>), ReplaceQueue(Vec<String>),
ClearQueue(bool), ClearQueue(bool),
/// Drop duplicate queue entries; the reply comes back as
/// [`MessageToUi::QueueDeduped`] (architecture/queue-order.md).
/// `by_title` asks for the aggressive same-song identity (D3a).
DedupQueue {
by_title: bool,
},
/// Reorder the queue by one strategy. Fire-and-forget: the new order
/// arrives on the update stream like every other queue change.
SortQueue {
sort: QueueSort,
descending: bool,
},
NextTrack, NextTrack,
PrevTrack, PrevTrack,
RestartTrack, RestartTrack,
@ -306,6 +325,10 @@ pub struct App {
pub input: Option<InputState>, pub input: Option<InputState>,
/// `Some` while a `/` search input is open; modal like the others. /// `Some` while a `/` search input is open; modal like the others.
pub search: Option<SearchState>, pub search: Option<SearchState>,
/// True while the queue sort menu is open. Modal like the others: keys go
/// to [`Self::handle_sort_key`] and the bindings table is unreachable
/// (architecture/queue-order.md D14).
pub sort_menu: bool,
/// Progress of running (and recently finished) captures, rendered as /// Progress of running (and recently finished) captures, rendered as
/// status lines at the bottom of the library pane. /// status lines at the bottom of the library pane.
pub captures: CaptureBoard, pub captures: CaptureBoard,
@ -328,6 +351,7 @@ impl App {
show_help: false, show_help: false,
input: None, input: None,
search: None, search: None,
sort_menu: false,
captures: CaptureBoard::default(), captures: CaptureBoard::default(),
library, library,
now_playing, now_playing,
@ -337,6 +361,31 @@ impl App {
} }
} }
/// Handles one key while the sort menu is open (`sort_menu`).
///
/// A key the menu claims sends the sort and closes; `Esc`, `q` and `S`
/// close without sorting; anything else is ignored and the menu stays up,
/// so a mistyped key does not silently drop back into the bindings table
/// where it would *do* something (architecture/queue-order.md D14).
pub fn handle_sort_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Esc => self.sort_menu = false,
KeyCode::Char(c) => {
if let Some((sort, descending)) = sort::choose(c) {
let _ = self.tx.send(MessageFromUi::SortQueue { sort, descending });
self.sort_menu = false;
} else if matches!(c, 'q' | 'S') {
// The keys that got here (and the one that opened it).
self.sort_menu = false;
}
}
// Every other key is swallowed: the menu stays up rather than
// letting a stray keypress reach the pane bindings.
_ => {}
}
}
/// Handles one key while a `/` search input is open /// Handles one key while a `/` search input is open
/// (`search.is_some()`). Typing filters the focused pane live; /// (`search.is_some()`). Typing filters the focused pane live;
/// `Enter` keeps the filter and returns to navigation; `Esc` clears /// `Enter` keeps the filter and returns to navigation; `Esc` clears
@ -700,6 +749,22 @@ impl App {
self.set_register(dropped); self.set_register(dropped);
let _ = self.tx.send(MessageFromUi::ClearQueue(false)); let _ = self.tx.send(MessageFromUi::ClearQueue(false));
} }
Action::QueueDedup | Action::QueueDedupTitles => {
// Server-side (architecture/queue-order.md D1): the count
// comes back as `MessageToUi::QueueDeduped`. Nothing to do on
// an empty queue.
if !self.queue.is_empty() {
let by_title = action == Action::QueueDedupTitles;
let _ = self.tx.send(MessageFromUi::DedupQueue { by_title });
}
}
Action::QueueSortMenu => {
// Opening on an empty queue would offer five ways to sort
// nothing.
if !self.queue.is_empty() {
self.sort_menu = true;
}
}
Action::QueueSaveAs => { Action::QueueSaveAs => {
// Nothing to save from an empty queue; silently ignored // Nothing to save from an empty queue; silently ignored
// like the other capability-gated openers. // like the other capability-gated openers.
@ -821,7 +886,12 @@ impl App {
} }
} }
// The help modal renders last so it overlays every pane. // The sort menu and the help modal render last so they overlay every
// pane. Both cannot be open at once (each is modal), so the order
// between them is arbitrary.
if self.sort_menu {
sort::render(f);
}
if self.show_help { if self.show_help {
help::render(f); help::render(f);
} }
@ -1754,6 +1824,93 @@ mod tests {
assert_eq!(input.buffer, ""); assert_eq!(input.buffer, "");
} }
// ---- queue order (architecture/queue-order.md) ---------------------
#[test]
fn dedup_asks_the_server_only_with_a_non_empty_queue() {
let (mut app, rx) = app();
assert_eq!(app.dispatch(Action::QueueDedup), DispatchResult::Continue);
assert!(rx.try_recv().is_err(), "nothing to dedup in an empty queue");
app.queue.update_queue(one_track_queue());
let _ = app.dispatch(Action::QueueDedup);
assert!(matches!(
rx.try_recv(),
Ok(MessageFromUi::DedupQueue { by_title: false })
));
// `U` is the same verb with the aggressive identity (D3a).
let _ = app.dispatch(Action::QueueDedupTitles);
assert!(matches!(
rx.try_recv(),
Ok(MessageFromUi::DedupQueue { by_title: true })
));
}
#[test]
fn the_sort_menu_opens_only_with_a_non_empty_queue() {
let (mut app, _rx) = app();
let _ = app.dispatch(Action::QueueSortMenu);
assert!(!app.sort_menu, "no menu for an empty queue");
app.queue.update_queue(one_track_queue());
let _ = app.dispatch(Action::QueueSortMenu);
assert!(app.sort_menu);
}
#[test]
fn a_sort_menu_key_sends_the_strategy_and_closes_the_menu() {
let (mut app, rx) = app();
app.queue.update_queue(one_track_queue());
let _ = app.dispatch(Action::QueueSortMenu);
app.handle_sort_key(key(crossterm::event::KeyCode::Char('A')));
assert!(!app.sort_menu, "picking a strategy closes the menu");
match rx.try_recv() {
Ok(MessageFromUi::SortQueue { sort, descending }) => {
assert_eq!(sort, QueueSort::Artist);
assert!(descending, "the capital sorts descending");
}
other => panic!("expected a sort message, got {:?}", other.is_ok()),
}
}
#[test]
fn escape_closes_the_sort_menu_without_sorting() {
let (mut app, rx) = app();
app.queue.update_queue(one_track_queue());
for closer in [
crossterm::event::KeyCode::Esc,
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyCode::Char('S'),
] {
let _ = app.dispatch(Action::QueueSortMenu);
app.handle_sort_key(key(closer));
assert!(!app.sort_menu, "{closer:?} must close the menu");
assert!(rx.try_recv().is_err(), "{closer:?} must not sort");
}
}
/// A key the menu does not offer keeps it open and does nothing. Falling
/// back to the bindings table would let a stray `c` clear the queue while
/// the user thinks they are choosing a sort
/// (architecture/queue-order.md D14).
#[test]
fn an_unknown_key_leaves_the_sort_menu_open() {
let (mut app, rx) = app();
app.queue.update_queue(one_track_queue());
let _ = app.dispatch(Action::QueueSortMenu);
for stray in [
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyCode::Enter,
] {
app.handle_sort_key(key(stray));
assert!(app.sort_menu, "{stray:?} must not close the menu");
assert!(rx.try_recv().is_err(), "{stray:?} must not do anything");
}
}
#[test] #[test]
fn queue_download_capture_targets_the_current_queue() { fn queue_download_capture_targets_the_current_queue() {
let (mut app, rx) = app(); let (mut app, rx) = app();

View File

@ -31,9 +31,16 @@ pub struct Queue {
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly /// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
/// as in the library (architecture/queue-register.md D7). /// as in the library (architecture/queue-register.md D7).
visual: Option<usize>, visual: Option<usize>,
/// The result of the last dedup and when it arrived: shown in the pane
/// title for [`NOTICE_LINGER`], then gone
/// (architecture/queue-order.md D15).
notice: Option<(String, std::time::Instant)>,
tx: Sender<MessageFromUi>, tx: Sender<MessageFromUi>,
} }
/// How long a dedup result stays in the pane title.
const NOTICE_LINGER: std::time::Duration = std::time::Duration::from_secs(4);
impl Queue { impl Queue {
pub fn new(tx: Sender<MessageFromUi>) -> Self { pub fn new(tx: Sender<MessageFromUi>) -> Self {
Self { Self {
@ -43,10 +50,33 @@ impl Queue {
filter: Filter::default(), filter: Filter::default(),
resolving: false, resolving: false,
visual: None, visual: None,
notice: None,
tx, tx,
} }
} }
/// Shows how many entries a dedup removed, in the pane title, for a few
/// seconds (architecture/queue-order.md D15). `0` is worth saying: it
/// means the queue held no duplicates.
pub fn show_dedup_result(&mut self, removed: u32) {
let text = match removed {
0 => "no duplicates".to_string(),
1 => "removed 1 duplicate".to_string(),
n => format!("removed {n} duplicates"),
};
self.notice = Some((text, std::time::Instant::now()));
}
/// The title note to render right now, or `None` once it has expired.
/// Separate from [`Self::show_dedup_result`] so the expiry is checked at
/// render time rather than on a timer.
fn notice(&self) -> Option<&str> {
self.notice
.as_ref()
.filter(|(_, at)| at.elapsed() < NOTICE_LINGER)
.map(|(text, _)| text.as_str())
}
/// The real queue position under the cursor, mapped through the /// The real queue position under the cursor, mapped through the
/// active filter — this is what the server-facing ops send. /// active filter — this is what the server-facing ops send.
fn selected_position(&self) -> Option<usize> { fn selected_position(&self) -> Option<usize> {
@ -290,15 +320,18 @@ impl Queue {
} else { } else {
COLOR_PRIMARY_DARK COLOR_PRIMARY_DARK
})) }))
.title(if self.visual.is_some() { // One title, several claimants: a mode the user is *in*
// Visual (paint-select) mode: movement toggles marks. // (visual, an active search) outranks a result they have
"Queue — VISUAL".to_string() // already been shown, which outranks the standing register
} else { // count (architecture/queue-order.md D15).
match (self.filter.query(), register_len) { .title(match (self.visual.is_some(), self.filter.query()) {
(Some(query), _) => format!("Queue — /{query}"), (true, _) => "Queue — VISUAL".to_string(),
(false, Some(query)) => format!("Queue — /{query}"),
(false, None) => match (self.notice(), register_len) {
(Some(notice), _) => format!("Queue — {notice}"),
(None, 0) => "Queue".to_string(), (None, 0) => "Queue".to_string(),
(None, n) => format!("Queue — register: {n}"), (None, n) => format!("Queue — register: {n}"),
} },
}), }),
) )
.highlight_style(Style::default().bg(if focused { .highlight_style(Style::default().bg(if focused {
@ -562,6 +595,62 @@ mod tests {
assert_eq!(queue.filter_query(), Some("beta")); assert_eq!(queue.filter_query(), Some("beta"));
} }
/// The dedup result is shown in the pane title, and `0` is shown too — it
/// is the answer "no duplicates" (architecture/queue-order.md D15).
#[test]
fn the_dedup_result_appears_in_the_title() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
queue.show_dedup_result(7);
let title = rendered_rows(&mut queue).remove(0);
assert!(title.contains('7'), "title: {title:?}");
// Zero is an answer, not a non-event: it has to say so in words a
// user can act on, not read as a dropped keypress.
queue.show_dedup_result(0);
let title = rendered_rows(&mut queue).remove(0);
assert!(
title.contains("no duplicates"),
"a zero count is still an answer: {title:?}"
);
}
#[test]
fn the_dedup_result_expires() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one"], false));
queue.show_dedup_result(3);
assert!(queue.notice().is_some());
// Backdate the stamp past the linger; the expiry is checked at render
// time, so nothing else has to run.
queue.notice = queue.notice.take().map(|(text, at)| {
(
text,
at - NOTICE_LINGER - std::time::Duration::from_millis(1),
)
});
assert!(queue.notice().is_none());
let title = rendered_rows(&mut queue).remove(0);
assert!(
!title.contains('3'),
"expired notice still shown: {title:?}"
);
}
/// The title has one slot and several claimants; a mode the user is *in*
/// outranks a result they have already seen.
#[test]
fn an_active_search_outranks_the_dedup_result() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta"], false));
queue.set_filter(Some("bet".to_string()));
queue.show_dedup_result(2);
let title = rendered_rows(&mut queue).remove(0);
assert!(title.contains("/bet"), "title: {title:?}");
}
#[test] #[test]
fn colored_rows_darken_under_the_focused_selection_bar() { fn colored_rows_darken_under_the_focused_selection_bar() {
// A red (skipped) row under the light focused selection bar was // A red (skipped) row under the light focused selection bar was

222
cbd-tui/src/app/sort.rs Normal file
View File

@ -0,0 +1,222 @@
//! The queue sort menu: a modal overlay that turns one keypress into a sort
//! strategy (architecture/queue-order.md D14).
//!
//! Opened with `S` in the queue pane. While it is open the bindings table is
//! unreachable, exactly like the help, input and search overlays: keys answer
//! *this* menu. The strategies live in one table so the overlay lists what it
//! accepts and nothing can drift out of it.
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use crabidy_core::proto::crabidy::QueueSort;
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
/// One row of the menu: the key that picks it, what it does, and the wire
/// strategy it maps to.
pub struct SortChoice {
/// Lowercase key; its uppercase form sorts descending (except
/// [`QueueSort::Reverse`], which has no direction).
pub key: char,
/// Shown verbatim in the overlay. Imperative, no trailing period.
pub description: &'static str,
pub sort: QueueSort,
}
/// The menu, in display order. `l` is album — `a` is taken by artist, and a
/// menu key only has to be unambiguous *inside the menu*, where the overlay
/// spells it out.
pub const SORT_MENU: &[SortChoice] = &[
SortChoice {
key: 'a',
description: "Artist, then album",
sort: QueueSort::Artist,
},
SortChoice {
key: 'l',
description: "Album",
sort: QueueSort::Album,
},
SortChoice {
key: 't',
description: "Title",
sort: QueueSort::Title,
},
SortChoice {
key: 'd',
description: "Duration",
sort: QueueSort::Duration,
},
SortChoice {
key: 'r',
description: "Reverse the current order",
sort: QueueSort::Reverse,
},
];
/// The strategy and direction a key picks, or `None` for a key the menu does
/// not claim (the caller then leaves the menu open).
///
/// The uppercase form of a key sorts descending. `QueueSort::Reverse` ignores
/// the direction on the wire (D6), so `R` and `r` are the same request.
pub fn choose(key: char) -> Option<(QueueSort, bool)> {
let lower = key.to_ascii_lowercase();
let entry = SORT_MENU.iter().find(|entry| entry.key == lower)?;
// Reverse has no direction on the wire, so `R` must not send a
// "descending reverse" the server would then ignore (D6).
let descending = key.is_ascii_uppercase() && entry.sort != QueueSort::Reverse;
Some((entry.sort, descending))
}
/// The footer line: how to sort descending, and how to leave.
const FOOTER: &str = "Capitals sort descending · Esc/q/S closes";
/// The menu's lines: a key column, the descriptions, and the footer.
fn lines() -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = SORT_MENU
.iter()
.map(|entry| {
Line::from(vec![
Span::styled(
format!(" {} ", entry.key),
Style::default()
.fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::from(format!(" {}", entry.description)),
])
})
.collect();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
FOOTER,
Style::default().fg(COLOR_SECONDARY),
)));
lines
}
/// Renders the menu as a `Clear`-backed popup centered in the frame, above
/// everything else in it.
pub fn render(f: &mut Frame) {
let area = popup_area(f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(COLOR_PRIMARY))
.title("Sort queue");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(lines()), inner);
}
/// The centered popup rectangle, sized to the menu but clamped to `frame`.
/// Overflowing lines are truncated rather than scrolled — the help modal's
/// rule, and this content is five rows.
fn popup_area(frame: Rect) -> Rect {
let content_width = lines()
.iter()
.map(|line| line.width() as u16)
.max()
.unwrap_or(0)
.max(FOOTER.chars().count() as u16);
let width = content_width.saturating_add(2).min(frame.width);
let height = (lines().len() as u16).saturating_add(2).min(frame.height);
Rect::new(
frame.x + (frame.width.saturating_sub(width)) / 2,
frame.y + (frame.height.saturating_sub(height)) / 2,
width,
height,
)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn a_lowercase_key_sorts_ascending_and_its_capital_descending() {
assert_eq!(choose('a'), Some((QueueSort::Artist, false)));
assert_eq!(choose('A'), Some((QueueSort::Artist, true)));
assert_eq!(choose('l'), Some((QueueSort::Album, false)));
assert_eq!(choose('t'), Some((QueueSort::Title, false)));
assert_eq!(choose('D'), Some((QueueSort::Duration, true)));
}
/// Reverse has no direction on the wire (D6), so both cases are the same
/// request — not a "descending reverse" that would silently be a no-op.
#[test]
fn reverse_ignores_the_case() {
assert_eq!(choose('r'), Some((QueueSort::Reverse, false)));
assert_eq!(choose('R'), Some((QueueSort::Reverse, false)));
}
#[test]
fn keys_the_menu_does_not_offer_are_not_claimed() {
for key in ['x', 'j', '1', ' ', 'q'] {
assert_eq!(choose(key), None, "{key:?} is not a strategy");
}
}
/// Every table row must be reachable by its own key, and no two rows may
/// claim the same one.
#[test]
fn the_table_is_consistent() {
let mut seen = Vec::new();
for entry in SORT_MENU {
assert!(
entry.key.is_ascii_lowercase(),
"{:?} must be a lowercase key so its capital is free for descending",
entry.key
);
assert!(!seen.contains(&entry.key), "duplicate key {:?}", entry.key);
assert!(!entry.description.trim().is_empty());
assert_eq!(choose(entry.key), Some((entry.sort, false)));
seen.push(entry.key);
}
}
#[test]
fn the_overlay_lists_every_strategy_with_its_key() {
let backend = TestBackend::new(60, 16);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(render).expect("draw must not panic");
let buffer = terminal.backend().buffer().clone();
let text: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
for entry in SORT_MENU {
assert!(
text.contains(entry.description),
"{:?} missing from the overlay:\n{text}",
entry.description
);
}
}
/// A frame smaller than the menu must clamp, not panic or draw outside it
/// — the help modal's rule.
#[test]
fn a_tiny_frame_clamps_the_popup() {
for (w, h) in [(80, 24), (20, 6), (4, 2), (1, 1)] {
let frame = Rect::new(0, 0, w, h);
let area = popup_area(frame);
assert!(area.width <= frame.width && area.height <= frame.height);
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(render).expect("draw must not panic");
}
}
}

View File

@ -7,6 +7,7 @@
pub mod app; pub mod app;
pub mod config; pub mod config;
pub mod rpc; pub mod rpc;
pub mod stderr;
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
pub mod mpris; pub mod mpris;
@ -96,7 +97,13 @@ async fn orchestrate(
let mut rpc_client = rpc::RpcClient::connect(&config.server).await?; let mut rpc_client = rpc::RpcClient::connect(&config.server).await?;
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? { if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?; if tx
.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))
.is_err()
{
info!("the ui closed before the library root arrived");
return Ok(());
}
} }
// A desktop that cannot host the player (no session bus) leaves this // A desktop that cannot host the player (no session bus) leaves this
@ -108,21 +115,40 @@ async fn orchestrate(
if let Some(mpris) = &mpris { if let Some(mpris) = &mpris {
mpris.publish_init(&init_data); mpris.publish_init(&init_data);
} }
tx.send_async(MessageToUi::Init(init_data)).await?; if tx.send_async(MessageToUi::Init(init_data)).await.is_err() {
info!("the ui closed before the initial state arrived");
return Ok(());
}
loop { loop {
if let Err(err) = poll(&mut rpc_client, &rx, &tx, &mpris).await { match poll(&mut rpc_client, &rx, &tx, &mpris).await {
error!("request to server failed: {err}"); Ok(Flow::Continue) => {}
// The UI thread owns the other end; quitting drops it. That is a
// shutdown, not a failed request — reporting it as one produced an
// ERROR line ("sending on a closed channel") on every clean exit,
// and the loop then spun on a stream nobody was listening to.
Ok(Flow::UiGone) => {
info!("the ui closed, stopping the orchestrator");
return Ok(());
}
Err(err) => error!("request to server failed: {err}"),
} }
} }
} }
/// What the orchestrator does after one poll.
enum Flow {
Continue,
/// The `MessageToUi` receiver is gone: the terminal UI has exited.
UiGone,
}
async fn poll( async fn poll(
rpc_client: &mut RpcClient, rpc_client: &mut RpcClient,
rx: &Receiver<MessageFromUi>, rx: &Receiver<MessageFromUi>,
tx: &Sender<MessageToUi>, tx: &Sender<MessageToUi>,
mpris: &Option<mpris::Feed>, mpris: &Option<mpris::Feed>,
) -> Result<(), Box<dyn Error>> { ) -> Result<Flow, Box<dyn Error>> {
select! { select! {
Ok(msg) = &mut rx.recv_async() => { Ok(msg) = &mut rx.recv_async() => {
match msg { match msg {
@ -218,6 +244,22 @@ async fn poll(
MessageFromUi::ClearQueue(exclude_current) => { MessageFromUi::ClearQueue(exclude_current) => {
rpc_client.clear_queue(exclude_current).await? rpc_client.clear_queue(exclude_current).await?
} }
MessageFromUi::DedupQueue { by_title } => {
// The count is the whole point (architecture/queue-order.md
// D2); a failure is logged and the queue simply stays as it
// is — it must not tear down the poll loop.
match rpc_client.dedup_queue(by_title).await {
Ok(removed) => {
let _ = tx.send(MessageToUi::QueueDeduped { removed });
}
Err(err) => error!("failed to dedup the queue: {err}"),
}
}
MessageFromUi::SortQueue { sort, descending } => {
if let Err(err) = rpc_client.sort_queue(sort, descending).await {
error!(?sort, descending, "failed to sort the queue: {err}");
}
}
MessageFromUi::SaveQueue(name) => { MessageFromUi::SaveQueue(name) => {
// A rejected save (bad name, empty queue) must not tear // A rejected save (bad name, empty queue) must not tear
// down the poll loop; the server logs the cause. // down the poll loop; the server logs the cause.
@ -246,7 +288,9 @@ async fn poll(
if let Some(mpris) = mpris { if let Some(mpris) = mpris {
mpris.publish(&update); mpris.publish(&update);
} }
tx.send_async(MessageToUi::Update(update)).await?; if tx.send_async(MessageToUi::Update(update)).await.is_err() {
return Ok(Flow::UiGone);
}
} }
} }
Err(err) => { Err(err) => {
@ -259,7 +303,7 @@ async fn poll(
} }
} }
Ok(()) Ok(Flow::Continue)
} }
fn run_ui( fn run_ui(
@ -334,6 +378,9 @@ fn run_ui(
app.now_playing.update_spectrum(frame.bins); app.now_playing.update_spectrum(frame.bins);
} }
}, },
MessageToUi::QueueDeduped { removed } => {
app.queue.show_dedup_result(removed);
}
} }
} }
@ -356,6 +403,8 @@ fn run_ui(
app.handle_search_key(key); app.handle_search_key(key);
} else if app.input.is_some() { } else if app.input.is_some() {
app.handle_input_key(key); app.handle_input_key(key);
} else if app.sort_menu {
app.handle_sort_key(key);
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) { } else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
if app.dispatch(action) == DispatchResult::Quit { if app.dispatch(action) == DispatchResult::Quit {
break; break;

View File

@ -18,14 +18,12 @@ const CONFIG_FILE: &str = "cbd-tui.toml";
static CONFIG: OnceLock<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
/// Logs to a file: the terminal is owned by the TUI, so writing log lines to /// Logs to a file: the terminal is owned by the TUI, so writing log lines to
/// stdout/stderr would corrupt the interface. /// stdout/stderr would corrupt the interface. The same reason stderr itself is
/// redirected — see [`cbd_tui::stderr`].
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> { fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter}; use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = dirs::state_dir() let log_dir = cbd_tui::stderr::log_dir();
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy");
if let Err(err) = std::fs::create_dir_all(&log_dir) { if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!( eprintln!(
"could not create log directory {}: {err}", "could not create log directory {}: {err}",
@ -56,6 +54,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// No subcommand: load config, apply overrides, run the TUI. // No subcommand: load config, apply overrides, run the TUI.
None => { None => {
let _log_guard = init_tracing(); let _log_guard = init_tracing();
// Before the alternate screen: from here on, a write to fd 2 that
// did not go through tracing (a panic message, ALSA's underrun
// chatter) would land on the interface.
let stderr_log = cbd_tui::stderr::log_dir().join("cbd-tui.stderr.log");
if let Err(err) = cbd_tui::stderr::capture_into(&stderr_log) {
eprintln!(
"could not redirect stderr to {}: {err}",
stderr_log.display()
);
}
let mut config = config::load_first_run(CONFIG_FILE); let mut config = config::load_first_run(CONFIG_FILE);
config::apply_overrides( config::apply_overrides(
&mut config, &mut config,

View File

@ -1,11 +1,11 @@
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest,
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, PrevRequest, QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
ToggleRepeatRequest, ToggleShuffleRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
}; };
use std::{collections::HashMap, error::Error, fmt, time::Duration}; use std::{collections::HashMap, error::Error, fmt, time::Duration};
@ -280,6 +280,33 @@ impl RpcClient {
Ok(()) Ok(())
} }
/// Drops duplicate queue entries and returns how many went
/// (architecture/queue-order.md D2) — the one queue verb with an answer
/// worth showing, because `0` means "no duplicates", not "nothing
/// happened".
pub async fn dedup_queue(&mut self, by_title: bool) -> Result<u32, Box<dyn Error>> {
let response = self
.client
.dedup_queue(Request::new(DedupQueueRequest { by_title }))
.await?;
Ok(response.into_inner().removed)
}
/// Reorders the queue server-side; the new order arrives on the update
/// stream like any other queue change.
pub async fn sort_queue(
&mut self,
sort: QueueSort,
descending: bool,
) -> Result<(), Box<dyn Error>> {
let request = Request::new(SortQueueRequest {
sort: sort as i32,
descending,
});
self.client.sort_queue(request).await?;
Ok(())
}
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> { pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
let save_queue_request = Request::new(SaveQueueRequest { name }); let save_queue_request = Request::new(SaveQueueRequest { name });
self.client.save_queue(save_queue_request).await?; self.client.save_queue(save_queue_request).await?;

112
cbd-tui/src/stderr.rs Normal file
View File

@ -0,0 +1,112 @@
//! Keeping foreign writes off the TUI's screen.
//!
//! `tracing` goes to a log file precisely because the terminal belongs to the
//! interface — but that only covers what *we* log. File descriptor 2 still
//! points at the terminal, and plenty in this process never goes through
//! `tracing`:
//!
//! - ALSA prints from C (`ALSA lib pcm.c:…: underrun occurred`), which the
//! bundled `cbd` sees because the audio stack shares its process.
//! - the default panic hook, and anything a dependency decides to
//! `eprintln!`.
//!
//! Those bytes land wherever the cursor is, scroll the terminal by a line and
//! leave the whole layout looking shifted — the queue appearing to bleed into
//! the now-playing pane. Because ratatui repaints only the cells that changed,
//! the damage persists until something forces a full redraw. Worse, the
//! message is then *lost*: it never reaches the log, so afterwards there is no
//! record of the underrun that caused it.
//!
//! So the fix is one `dup2`: point fd 2 at a file next to the log before the
//! alternate screen is entered. The diagnostics are kept, just not on screen.
use std::path::{Path, PathBuf};
/// The directory both binaries log into: `crabidy/` under the state dir,
/// falling back to the cache dir and then to the temp dir.
///
/// Shared so the log file and the captured stderr always land together.
pub fn log_dir() -> PathBuf {
dirs::state_dir()
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy")
}
/// Points file descriptor 2 at `path` (appending), so writes that bypass
/// `tracing` are recorded instead of scribbling on the interface.
///
/// Call once, before the terminal is put into raw mode. Errors are returned
/// rather than handled: a client that cannot open its log file should still
/// start — it just keeps the noisy stderr it has always had.
#[cfg(unix)]
pub fn capture_into(path: &Path) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
// `dup2` makes fd 2 a second reference to this file, so dropping `file`
// (and its own descriptor) at the end of this function leaves stderr
// pointing at it.
if unsafe { libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO) } == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// Nothing to do off Unix: there is no `dup2`, and the crabidy clients are
/// terminal programs on Linux and macOS.
#[cfg(not(unix))]
pub fn capture_into(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The redirect must actually move the process's stderr, and appending
/// must not truncate what an earlier run wrote.
///
/// The write goes to the descriptor directly rather than through
/// `eprintln!`: that is what a C library does (the case this exists for),
/// and the test harness intercepts the macro but not the descriptor.
#[cfg(unix)]
#[test]
fn captured_stderr_reaches_the_file_and_appends() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cbd.stderr.log");
std::fs::write(&path, "earlier run\n").expect("seed");
// Keep the real stderr so the rest of the suite still has one. Briefly
// process-wide, so a parallel test writing to fd 2 in this window would
// land in the file too — harmless here, and the alternative is a child
// process for one assertion.
let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
assert!(saved >= 0, "could not save stderr");
capture_into(&path).expect("redirect");
let wrote = std::io::stderr().write_all(b"underrun occurred\n");
let flushed = std::io::stderr().flush();
let restored = unsafe { libc::dup2(saved, libc::STDERR_FILENO) };
assert!(restored >= 0, "could not restore stderr");
unsafe { libc::close(saved) };
wrote.expect("write to the redirected stderr");
flushed.expect("flush");
let captured = std::fs::read_to_string(&path).expect("read back");
assert!(captured.starts_with("earlier run\n"), "{captured:?}");
assert!(captured.contains("underrun occurred"), "{captured:?}");
}
#[test]
fn the_log_dir_is_under_crabidy() {
assert_eq!(
log_dir().file_name().and_then(|n| n.to_str()),
Some("crabidy")
);
}
}

View File

@ -601,7 +601,44 @@ impl Store {
})); }));
} }
} }
Action::QueueDedup | Action::QueueDedupTitles => {
if self.queue.with_untracked(|q| !q.is_empty()) {
let by_title = action == Action::QueueDedupTitles;
let this = *self;
let Some(mut rpc) = self.rpc() else { return };
spawn_local(async move {
match rpc.dedup_queue(by_title).await {
// 0 is an answer, not a non-event
// (architecture/queue-order.md D2).
Ok(removed) => this.notify(format!("removed {removed} duplicate(s)")),
Err(status) => this.fail(status),
} }
});
}
}
Action::QueueSortMenu => {
// Five ways to sort nothing is not a menu.
if self.queue.with_untracked(|q| !q.is_empty()) {
self.dialog.set(Some(Dialog::Sort));
}
}
Action::CloseSortMenu => self.dialog.set(None),
Action::QueueSort { sort, descending } => {
self.dialog.set(None);
self.call(async move |mut rpc: Rpc| rpc.sort_queue(sort, descending).await)
}
}
}
/// Shows a plain notice in the same toast errors use — a result the user
/// asked for, not a failure (architecture/queue-order.md D16).
fn notify(&self, message: String) {
let toast = self.toast;
toast.set(Some(message));
spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(TOAST_MS).await;
toast.set(None);
});
} }
/// Submits the name dialog (Enter) — the TUI's `handle_input_key` /// Submits the name dialog (Enter) — the TUI's `handle_input_key`
@ -759,18 +796,24 @@ pub fn App() -> impl IntoView {
// their own keys), and browser defaults for handled chords are // their own keys), and browser defaults for handled chords are
// suppressed so Space does not scroll or Tab move focus. // suppressed so Space does not scroll or Tab move focus.
let _handle = window_event_listener(leptos::ev::keydown, move |ev| { let _handle = window_event_listener(leptos::ev::keydown, move |ev| {
let dialog_open = store.dialog.with_untracked(Option::is_some); let dialog = store.dialog.get_untracked();
let help_open = matches!(store.dialog.get_untracked(), Some(Dialog::Help)); let help_open = matches!(dialog, Some(Dialog::Help));
if dialog_open && !help_open { // Help and the sort menu are the two dialogs that *answer* keys; the
// rest (text input, login) own their fields.
let sort_open = matches!(dialog, Some(Dialog::Sort));
if dialog.is_some() && !help_open && !sort_open {
return; return;
} }
if ev.alt_key() || ev.meta_key() { if ev.alt_key() || ev.meta_key() {
return; return;
} }
let key = ev.key(); let key = ev.key();
if let Some(action) = let action = if sort_open {
keymap::sort_menu_key(&key)
} else {
keymap::lookup(store.focus.get_untracked(), help_open, &key, ev.ctrl_key()) keymap::lookup(store.focus.get_untracked(), help_open, &key, ev.ctrl_key())
{ };
if let Some(action) = action {
ev.prevent_default(); ev.prevent_default();
store.dispatch(action); store.dispatch(action);
} }
@ -1093,6 +1136,24 @@ fn QueueView(store: Store) -> impl IntoView {
<span class="mode">"VISUAL"</span> <span class="mode">"VISUAL"</span>
</Show> </Show>
<span class="spacer"></span> <span class="spacer"></span>
// The two order operations. Buttons as well as keys: `u` and
// `S` are invisible to a mouse (architecture/queue-order.md
// D16), and "sort" opens the same menu the key does, so the
// strategy list exists exactly once.
<button
class="ghost"
title="sort the queue (S)"
on:click=move |_| store.dispatch(Action::QueueSortMenu)
>
"sort"
</button>
<button
class="ghost"
title="drop duplicate tracks (u)"
on:click=move |_| store.dispatch(Action::QueueDedup)
>
"dedup"
</button>
<button <button
class="ghost" class="ghost"
title="paste the register after selected (p)" title="paste the register after selected (p)"
@ -1320,10 +1381,52 @@ fn Dialogs(store: Store) -> impl IntoView {
} }
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(), Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(), Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(),
Dialog::Sort => view! { <SortMenu store=store /> }.into_any(),
}) })
} }
} }
/// The queue sort menu (`S`): the same five strategies as the TUI, each a
/// keypress or a click (architecture/queue-order.md D16). Keys are handled by
/// the global listener through `keymap::sort_menu_key`, so a row's click and
/// its letter dispatch the very same action.
#[component]
fn SortMenu(store: Store) -> impl IntoView {
view! {
<div class="overlay" on:click=move |_| store.dialog.set(None)>
<div class="dialog" on:click=|ev| ev.stop_propagation()>
<h2>"Sort queue"</h2>
<table>
<tbody>
{keymap::SORT_CHOICES
.iter()
.map(|choice| {
let sort = choice.sort;
view! {
<tr
class="sort-choice"
on:click=move |_| {
store
.dispatch(Action::QueueSort {
sort,
descending: false,
})
}
>
<td class="key">{choice.key.to_string()}</td>
<td>{choice.description}</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
<p class="hint">"Capitals sort descending · Esc closes"</p>
</div>
</div>
}
}
#[component] #[component]
fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView { fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView {
let value = RwSignal::new(buffer); let value = RwSignal::new(buffer);

View File

@ -3,6 +3,8 @@
//! Deliberate differences: there is no `q` (quit) in a browser tab, and //! Deliberate differences: there is no `q` (quit) in a browser tab, and
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`). //! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
use crabidy_core::proto::crabidy::QueueSort;
use crate::state::Focus; use crate::state::Focus;
/// Everything a key can trigger. Mirrors the TUI's `Action` list; the /// Everything a key can trigger. Mirrors the TUI's `Action` list; the
@ -64,6 +66,86 @@ pub enum Action {
QueueClearKeepCurrent, QueueClearKeepCurrent,
QueueClearAll, QueueClearAll,
QueueSaveAs, QueueSaveAs,
/// Drop duplicate queue entries server-side; the count lands in a toast
/// (architecture/queue-order.md D16).
QueueDedup,
/// Drop entries with the same artist and title, keeping the longest take.
/// Aggressive and opt-in (architecture/queue-order.md D3a).
QueueDedupTitles,
/// Open the sort menu dialog.
QueueSortMenu,
/// Close it without sorting (`Escape`/`q`/`S`).
CloseSortMenu,
/// Sort by one strategy — what a sort-menu key or the header's select
/// control dispatches.
QueueSort {
sort: QueueSort,
descending: bool,
},
}
/// One row of the sort menu: the key that picks it and what it does. The same
/// table drives the overlay and the header's select control, so the two can
/// never offer different strategies (architecture/queue-order.md D16).
pub struct SortChoice {
/// Lowercase key; the uppercase form sorts descending, except
/// [`QueueSort::Reverse`], which has no direction.
pub key: char,
pub description: &'static str,
pub sort: QueueSort,
}
/// The sort menu, in display order — the TUI's table, key for key
/// (`cbd-tui/src/app/sort.rs`).
pub const SORT_CHOICES: &[SortChoice] = &[
SortChoice {
key: 'a',
description: "Artist, then album",
sort: QueueSort::Artist,
},
SortChoice {
key: 'l',
description: "Album",
sort: QueueSort::Album,
},
SortChoice {
key: 't',
description: "Title",
sort: QueueSort::Title,
},
SortChoice {
key: 'd',
description: "Duration",
sort: QueueSort::Duration,
},
SortChoice {
key: 'r',
description: "Reverse the current order",
sort: QueueSort::Reverse,
},
];
/// The action a key pressed inside the sort menu maps to: a sort, closing the
/// menu (`Escape`/`q`/`S`), or nothing at all — an unclaimed key leaves the
/// menu open rather than falling through to the pane bindings.
pub fn sort_menu_key(key: &str) -> Option<Action> {
if matches!(key, "Escape" | "q" | "S") {
return Some(Action::CloseSortMenu);
}
let mut chars = key.chars();
let (typed, rest) = (chars.next()?, chars.next());
if rest.is_some() {
// A named key ("Enter", "ArrowDown"): not a strategy.
return None;
}
let choice = SORT_CHOICES
.iter()
.find(|choice| choice.key == typed.to_ascii_lowercase())?;
Some(Action::QueueSort {
sort: choice.sort,
// Reverse has no direction on the wire (D6).
descending: typed.is_ascii_uppercase() && choice.sort != QueueSort::Reverse,
})
} }
/// One row of the help overlay: the key label and what it does. /// One row of the help overlay: the key label and what it does.
@ -271,6 +353,21 @@ pub const HELP: &[HelpEntry] = &[
key: "C", key: "C",
description: "Clear entire queue", description: "Clear entire queue",
}, },
HelpEntry {
scope: "Queue",
key: "u",
description: "Unique: drop duplicate tracks from the queue",
},
HelpEntry {
scope: "Queue",
key: "U",
description: "Unique by title: one entry per song, keeping the longest",
},
HelpEntry {
scope: "Queue",
key: "S",
description: "Sort the queue (opens a menu of strategies)",
},
HelpEntry { HelpEntry {
scope: "Queue", scope: "Queue",
key: "w", key: "w",
@ -370,6 +467,9 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"c" => Some(Action::QueueClearKeepCurrent), "c" => Some(Action::QueueClearKeepCurrent),
"C" => Some(Action::QueueClearAll), "C" => Some(Action::QueueClearAll),
"w" => Some(Action::QueueSaveAs), "w" => Some(Action::QueueSaveAs),
"u" => Some(Action::QueueDedup),
"U" => Some(Action::QueueDedupTitles),
"S" => Some(Action::QueueSortMenu),
_ => None, _ => None,
}, },
} }
@ -471,6 +571,76 @@ mod tests {
); );
} }
/// The queue-order keys are the TUI's, in the queue pane only
/// (architecture/queue-order.md D16).
#[test]
fn queue_order_keys_match_the_tui() {
assert_eq!(
lookup(Focus::Queue, false, "u", false),
Some(Action::QueueDedup)
);
assert_eq!(
lookup(Focus::Queue, false, "S", false),
Some(Action::QueueSortMenu)
);
assert_eq!(
lookup(Focus::Queue, false, "U", false),
Some(Action::QueueDedupTitles)
);
assert_eq!(lookup(Focus::Library, false, "u", false), None);
assert_eq!(lookup(Focus::Library, false, "U", false), None);
assert_eq!(lookup(Focus::Library, false, "S", false), None);
}
#[test]
fn the_sort_menu_maps_keys_to_strategies_and_closes_on_escape() {
assert_eq!(
sort_menu_key("a"),
Some(Action::QueueSort {
sort: QueueSort::Artist,
descending: false
})
);
assert_eq!(
sort_menu_key("T"),
Some(Action::QueueSort {
sort: QueueSort::Title,
descending: true
})
);
// Reverse has no direction on the wire (D6).
for key in ["r", "R"] {
assert_eq!(
sort_menu_key(key),
Some(Action::QueueSort {
sort: QueueSort::Reverse,
descending: false
})
);
}
for closer in ["Escape", "q", "S"] {
assert_eq!(sort_menu_key(closer), Some(Action::CloseSortMenu));
}
// An unclaimed key leaves the menu open and does nothing.
for stray in ["c", "x", "Enter", "ArrowDown"] {
assert_eq!(sort_menu_key(stray), None, "{stray} is not a strategy");
}
}
/// Both menus offer the same strategies under the same keys, which is only
/// true while this table and `cbd-tui/src/app/sort.rs` agree.
#[test]
fn the_sort_table_is_consistent() {
let mut seen = Vec::new();
for choice in SORT_CHOICES {
assert!(choice.key.is_ascii_lowercase(), "{:?}", choice.key);
assert!(!seen.contains(&choice.key), "duplicate {:?}", choice.key);
assert!(!choice.description.trim().is_empty());
seen.push(choice.key);
}
assert_eq!(seen, vec!['a', 'l', 't', 'd', 'r']);
}
#[test] #[test]
fn every_action_reachable_from_help_table() { fn every_action_reachable_from_help_table() {
// The help overlay documents at least every scope we bind. // The help overlay documents at least every scope we bind.

View File

@ -6,12 +6,12 @@
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, GetUpdateStreamResponse, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
ToggleShuffleRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
}; };
use tonic::{ use tonic::{
metadata::MetadataValue, metadata::MetadataValue,
@ -230,6 +230,26 @@ impl Rpc {
Ok(()) Ok(())
} }
/// Drops duplicate queue entries; returns how many went
/// (architecture/queue-order.md D2).
pub async fn dedup_queue(&mut self, by_title: bool) -> Result<u32, Status> {
let response = self
.client
.dedup_queue(Request::new(DedupQueueRequest { by_title }))
.await?;
Ok(response.into_inner().removed)
}
/// Reorders the queue; the new order arrives on the update stream.
pub async fn sort_queue(&mut self, sort: QueueSort, descending: bool) -> Result<(), Status> {
let request = Request::new(SortQueueRequest {
sort: sort as i32,
descending,
});
let _ = self.client.sort_queue(request).await?;
Ok(())
}
pub async fn set_current(&mut self, position: u32) -> Result<(), Status> { pub async fn set_current(&mut self, position: u32) -> Result<(), Status> {
let request = Request::new(SetCurrentRequest { position }); let request = Request::new(SetCurrentRequest { position });
let _ = self.client.set_current(request).await?; let _ = self.client.set_current(request).await?;

View File

@ -58,6 +58,10 @@ pub enum Dialog {
Login, Login,
/// The `?` key binding overlay. /// The `?` key binding overlay.
Help, Help,
/// The queue sort menu (`S`): one keypress picks a strategy. Keys reach
/// the dispatcher while it is open, like `Help`
/// (architecture/queue-order.md D14, D16).
Sort,
} }
/// Whether a library listing may be cached client-side — same rule as /// Whether a library listing may be cached client-side — same rule as

View File

@ -470,6 +470,32 @@ input {
} }
} }
/* The queue sort menu: one row per strategy, pickable by click as well as by
its letter (architecture/queue-order.md D16). */
.sort-choice {
cursor: pointer;
& td {
padding: 0.25rem 0.6rem 0.25rem 0;
}
& .key {
font-family: ui-monospace, monospace;
color: var(--accent);
white-space: nowrap;
}
&:hover {
background: var(--accent-soft);
}
}
.hint {
margin: 0;
color: var(--fg-dim);
font-size: 0.85rem;
}
.toast { .toast {
position: fixed; position: fixed;
inset-block-end: 4.5rem; inset-block-end: 4.5rem;

View File

@ -53,6 +53,16 @@ async fn run_bundle(cli: CbdCli) -> Result<(), Box<dyn Error>> {
// Both halves share one file-based subscriber: the terminal belongs // Both halves share one file-based subscriber: the terminal belongs
// to the TUI, so the server's usual stderr logging would corrupt it. // to the TUI, so the server's usual stderr logging would corrupt it.
let _log_guard = init_tracing(); let _log_guard = init_tracing();
// Tracing is not the only writer, though. The audio stack runs *in this
// process* here, so ALSA's "underrun occurred" prints — and any panic
// message — would go straight onto the interface (cbd_tui::stderr).
let stderr_log = cbd_tui::stderr::log_dir().join("cbd.stderr.log");
if let Err(err) = cbd_tui::stderr::capture_into(&stderr_log) {
eprintln!(
"could not redirect stderr to {}: {err}",
stderr_log.display()
);
}
// `cbd` reads its OWN config (`cbd.toml`), separate from the // `cbd` reads its OWN config (`cbd.toml`), separate from the
// standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on // standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on
// one machine — `cbd` self-contained against its in-process server, // one machine — `cbd` self-contained against its in-process server,
@ -182,10 +192,7 @@ async fn wait_for_server(
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> { fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter}; use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = dirs::state_dir() let log_dir = cbd_tui::stderr::log_dir();
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy");
if let Err(err) = std::fs::create_dir_all(&log_dir) { if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!( eprintln!(
"could not create log directory {}: {err}", "could not create log directory {}: {err}",

View File

@ -41,6 +41,14 @@ service CrabidyService {
rpc Remove(RemoveRequest) returns (RemoveResponse); rpc Remove(RemoveRequest) returns (RemoveResponse);
rpc Insert(InsertRequest) returns (InsertResponse); rpc Insert(InsertRequest) returns (InsertResponse);
rpc ClearQueue(ClearQueueRequest) returns (ClearQueueResponse); rpc ClearQueue(ClearQueueRequest) returns (ClearQueueResponse);
// Drops duplicate entries, keeping one copy of each track. Never removes
// the playing entry, so it cannot interrupt playback. Answers with the
// number of entries removed (architecture/queue-order.md D2).
rpc DedupQueue(DedupQueueRequest) returns (DedupQueueResponse);
// Reorders the queue by one of the `QueueSort` strategies. The playing
// track keeps playing at its new position; the new order arrives on the
// update stream (architecture/queue-order.md).
rpc SortQueue(SortQueueRequest) returns (SortQueueResponse);
rpc SetCurrent(SetCurrentRequest) returns (SetCurrentResponse); rpc SetCurrent(SetCurrentRequest) returns (SetCurrentResponse);
rpc ToggleShuffle(ToggleShuffleRequest) returns (ToggleShuffleResponse); rpc ToggleShuffle(ToggleShuffleRequest) returns (ToggleShuffleResponse);
rpc ToggleRepeat(ToggleRepeatRequest) returns (ToggleRepeatResponse); rpc ToggleRepeat(ToggleRepeatRequest) returns (ToggleRepeatResponse);
@ -182,6 +190,59 @@ message ClearQueueRequest {
} }
message ClearQueueResponse {} message ClearQueueResponse {}
// De-duplication of the live queue (architecture/queue-order.md). Two
// entries are the same track when they carry the same `provider_item_id`
// under the same provider (the first path segment) or when that id is
// empty the same `path`. Deliberately *not* metadata matching: the same
// artist and title is routinely a different recording (D3).
message DedupQueueRequest {
// Collapse entries with the same artist and title instead the same song
// whatever the recording, so a remix, a radio edit and the album version
// reduce to one. Aggressive by construction: it discards versions the user
// may have queued deliberately, which is why it is opt-in and why the
// default above only merges what is provably the same item. The survivor is
// the playing entry, else the *longest* take (the full version rather than
// an edit), else the earliest.
bool by_title = 1;
}
message DedupQueueResponse {
// How many entries were dropped. 0 says the queue held no duplicates,
// which is not the same answer as "nothing happened" hence a response
// field where the other queue verbs have none (D2).
uint32 removed = 1;
}
// How `SortQueue` orders the queue.
//
// Every strategy is a *stable* sort, so entries with an equal key keep their
// queue order for a queued album that is its track order. Text compares
// case-insensitively and without collation; blank text and unknown durations
// sort last in **both** directions (architecture/queue-order.md D7, D8, D9).
enum QueueSort {
// A client that did not set the field. Rejected as invalid rather than
// silently treated as one of the strategies below.
QUEUE_SORT_UNSPECIFIED = 0;
// Artist, then album title; queue order within an album.
QUEUE_SORT_ARTIST = 1;
// Album title; queue order within an album.
QUEUE_SORT_ALBUM = 2;
// Track title.
QUEUE_SORT_TITLE = 3;
// Duration; tracks with no known duration (web radio) sort last.
QUEUE_SORT_DURATION = 4;
// Reverses the order the queue is currently in. Not a key: `descending`
// is ignored, since reversing descendingly is the same operation.
QUEUE_SORT_REVERSE = 5;
}
message SortQueueRequest {
QueueSort sort = 1;
// Reverses the comparison of `sort` (largest/last first). Ignored by
// `QUEUE_SORT_REVERSE`. Blanks and unknown durations still sort last.
bool descending = 2;
}
message SortQueueResponse {}
// Stream // Stream
message GetUpdateStreamRequest {} message GetUpdateStreamRequest {}
message GetUpdateStreamResponse { message GetUpdateStreamResponse {

View File

@ -73,9 +73,11 @@ pub fn minimum_role(grpc_path: &str) -> Role {
Role::QueueAppender Role::QueueAppender
} }
// Every other queue and playback verb. // Every other queue and playback verb.
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent" "Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "DedupQueue" | "SortQueue"
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume" | "SetCurrent" | "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop"
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => Role::QueueOwner, | "ChangeVolume" | "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => {
Role::QueueOwner
}
// Library writes (CaptureLibraryNode, SaveQueue, // Library writes (CaptureLibraryNode, SaveQueue,
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped. // RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
_ => Role::Owner, _ => Role::Owner,
@ -345,6 +347,8 @@ mod tests {
"Remove", "Remove",
"Insert", "Insert",
"ClearQueue", "ClearQueue",
"DedupQueue",
"SortQueue",
"SetCurrent", "SetCurrent",
"ToggleShuffle", "ToggleShuffle",
"ToggleRepeat", "ToggleRepeat",

View File

@ -15,6 +15,7 @@ pub mod crabidy_store;
pub mod orphans; pub mod orphans;
pub mod playback; pub mod playback;
pub mod provider; pub mod provider;
pub mod queue_order;
pub mod rpc; pub mod rpc;
pub mod settings; pub mod settings;
#[cfg(feature = "spectrum")] #[cfg(feature = "spectrum")]
@ -23,7 +24,7 @@ pub mod spectrum;
use audio_player::PlayerMessage; use audio_player::PlayerMessage;
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue, crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
Track, QueueSort, Track,
}; };
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use rand::{rng, seq::SliceRandom}; use rand::{rng, seq::SliceRandom};
@ -183,32 +184,36 @@ fn spawn_spectrum_task(
const FPS: u64 = 20; const FPS: u64 = 20;
tokio::spawn(async move { tokio::spawn(async move {
let mut analyzer = spectrum::SpectrumAnalyzer::new(audio_player::SPECTRUM_WINDOW); let mut analyzer = spectrum::SpectrumAnalyzer::new(audio_player::SPECTRUM_WINDOW);
let mut last_count = tap.frame_count(); let mut flow = spectrum::FlowDetector::new(tap.frame_count());
let mut was_active = false;
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000 / FPS)); let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000 / FPS));
// Each tick costs a little (an FFT and a broadcast), and the default
// `Burst` behaviour keeps the *absolute* schedule: once that cost has
// accumulated to a whole period, two ticks fire back to back and the
// second one necessarily sees no new frames. That is what made the bars
// flick to the floor roughly once a second during playback. `Delay`
// measures each period from the previous tick instead, so a tick is
// never spent catching up.
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
interval.tick().await; interval.tick().await;
// Nobody watching: do no work. // Nobody watching: do no work.
if update_tx.receiver_count() == 0 { if update_tx.receiver_count() == 0 {
continue; continue;
} }
let count = tap.frame_count(); match flow.observe(tap.frame_count()) {
if count != last_count { spectrum::Tick::Bars => {
last_count = count;
if !was_active {
debug!("spectrum: audio flowing, streaming bars");
}
was_active = true;
let bins = analyzer.analyze(&tap.snapshot()); let bins = analyzer.analyze(&tap.snapshot());
let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins })); let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins }));
} else if was_active { }
// Playback just went idle: drop the bars to the floor once. spectrum::Tick::Silence => {
// Playback went idle: drop the bars to the floor once.
debug!("spectrum: audio idle, bars to zero"); debug!("spectrum: audio idle, bars to zero");
was_active = false;
let _ = update_tx.send(Update::Spectrum(SpectrumFrame { let _ = update_tx.send(Update::Spectrum(SpectrumFrame {
bins: vec![0.0; spectrum::SPECTRUM_BINS], bins: vec![0.0; spectrum::SPECTRUM_BINS],
})); }));
} }
spectrum::Tick::Nothing => {}
}
} }
}); });
} }
@ -664,6 +669,76 @@ impl QueueManager {
!exclude_current !exclude_current
} }
/// Drops duplicate entries and returns how many were removed
/// (architecture/queue-order.md D3D5).
///
/// Two entries are the same track by [`queue_order::track_id`] under
/// `identity` — the provably-same provider item by default, or the same
/// artist and title when the caller opts in (D3a). The playing entry always
/// survives its group, so this never changes what is playing: the removal
/// goes through [`Self::remove_tracks`], which therefore reports no
/// successor to start.
pub fn dedup(&mut self, identity: queue_order::Identity) -> usize {
let positions =
queue_order::duplicate_positions(&self.tracks, self.current_position(), identity);
if positions.is_empty() {
return 0;
}
let removed = positions.len();
let positions: Vec<u32> = positions.iter().map(|p| *p as u32).collect();
// Never the current track (D4), so this cannot report a successor to
// start — and if it ever did, ignoring it would be the bug.
debug_assert!(
!positions.contains(&(self.current_position() as u32)),
"dedup must not remove the playing track"
);
let _ = self.remove_tracks(&positions);
removed
}
/// Reorders the queue by `sort` (architecture/queue-order.md D6D10).
///
/// The current track stays current — its position changes, and playback
/// is untouched. What plays *next* depends on the shuffle modifier (D10):
/// with shuffle off the play order is rebuilt from the new queue order
/// (sorting is the point), with shuffle on the existing shuffled sequence
/// is remapped and preserved.
///
/// [`QueueSort::Unspecified`] is a no-op; the RPC layer rejects it before
/// it gets here.
pub fn sort(&mut self, sort: QueueSort, descending: bool) {
let perm = queue_order::sort_permutation(&self.tracks, sort, descending);
if perm.len() != self.tracks.len() {
error!("sort permutation does not cover the queue, leaving it alone");
return;
}
// `old_to_new[old] = new`: where each track ended up.
let mut old_to_new = vec![0usize; perm.len()];
for (new, &old) in perm.iter().enumerate() {
old_to_new[old] = new;
}
let current = self.current_position();
let mut sorted: Vec<Track> = Vec::with_capacity(perm.len());
for &old in &perm {
sorted.push(self.tracks[old].clone());
}
self.tracks = sorted;
if self.shuffle {
// The user asked for a random play order; sorting rewrites what is
// *displayed*, so the remaining shuffled sequence — and the
// position within it — carry over unchanged (D10).
for index in self.play_order.iter_mut() {
*index = old_to_new[*index];
}
} else {
// Queue order *is* play order here, so the sort decides what plays
// next; the current track keeps playing, at its new index.
self.play_order = (0..self.tracks.len()).collect();
self.current_offset = old_to_new.get(current).copied().unwrap_or(0);
}
}
/// Restores play_order to a consistent state after an inconsistency was /// Restores play_order to a consistent state after an inconsistency was
/// detected. Loses shuffle history but keeps the queue playable. /// detected. Loses shuffle history but keeps the queue playable.
fn rebuild_play_order(&mut self) { fn rebuild_play_order(&mut self) {
@ -897,6 +972,175 @@ mod tests {
order.sort_unstable(); order.sort_unstable();
assert_eq!(order, (0..7).collect::<Vec<usize>>()); assert_eq!(order, (0..7).collect::<Vec<usize>>());
} }
// ---- dedup and sort (architecture/queue-order.md) -------------------
/// A track whose artist/title/duration are what a sort reads. The path is
/// unique per title so dedup does not conflate them.
fn meta(artist: &str, title: &str, duration: Option<u32>) -> Track {
Track {
path: format!("/tidal/x/{artist}-{title}"),
artist: artist.to_string(),
title: title.to_string(),
duration,
..track(0)
}
}
fn queue_of(tracks: Vec<Track>) -> QueueManager {
let mut q = QueueManager::new();
q.replace_with_tracks(&tracks);
q
}
/// The play sequence from where the queue stands now, to the end.
fn play_sequence(q: &mut QueueManager) -> Vec<String> {
let mut seq = vec![q.current_track().expect("a current track").title];
while let Some(next) = q.next_track() {
seq.push(next.title);
}
seq
}
/// The play order must always stay a permutation of the track indices —
/// the invariant every mutation shares (see `shuffle_insert_keeps_order_unique`).
fn assert_order_is_a_permutation(q: &QueueManager) {
let mut order = q.play_order.clone();
order.sort_unstable();
assert_eq!(
order,
(0..q.tracks.len()).collect::<Vec<usize>>(),
"play_order must index every track exactly once"
);
assert!(
q.tracks.is_empty() || q.current_offset < q.play_order.len(),
"current_offset must point into play_order"
);
}
#[test]
fn dedup_removes_later_copies_and_reports_the_count() {
let mut q = queue_of(vec![track(0), track(1), track(0), track(2), track(1)]);
assert_eq!(q.dedup(queue_order::Identity::ProviderItem), 2);
assert_eq!(titles(&q), vec!["track 0", "track 1", "track 2"]);
assert_order_is_a_permutation(&q);
}
#[test]
fn dedup_keeps_the_playing_track_playing() {
// Position 2 duplicates position 0 and is the one playing: dedup must
// drop position 0 and leave the current track current (D4).
let mut q = queue_of(vec![track(0), track(1), track(0)]);
assert!(q.set_current_position(2));
let playing = q.current_track().expect("current");
assert_eq!(q.dedup(queue_order::Identity::ProviderItem), 1);
assert_eq!(
q.current_track().expect("still a current track").path,
playing.path
);
assert_eq!(titles(&q), vec!["track 1", "track 0"]);
assert_order_is_a_permutation(&q);
}
#[test]
fn dedup_of_a_clean_queue_changes_nothing() {
let mut q = queue_of(vec![track(0), track(1)]);
assert_eq!(q.dedup(queue_order::Identity::ProviderItem), 0);
assert_eq!(titles(&q), vec!["track 0", "track 1"]);
// And an empty queue is not a panic.
let mut empty = QueueManager::new();
assert_eq!(empty.dedup(queue_order::Identity::ProviderItem), 0);
}
#[test]
fn dedup_leaves_the_queue_playable() {
let mut q = queue_of(vec![track(0), track(0), track(1)]);
q.dedup(queue_order::Identity::ProviderItem);
assert_eq!(q.next_track().expect("advances").title, "track 1");
assert!(q.next_track().is_none());
}
#[test]
fn sorting_reorders_the_queue_and_follows_the_current_track() {
let mut q = queue_of(vec![
meta("b", "beta", None),
meta("a", "alpha", None),
meta("c", "gamma", None),
]);
assert!(q.set_current_position(0)); // "beta" is playing
q.sort(QueueSort::Artist, false);
assert_eq!(titles(&q), vec!["alpha", "beta", "gamma"]);
assert_eq!(
q.current_track().expect("current").title,
"beta",
"the playing track stays current at its new position"
);
assert_eq!(q.current_position(), 1);
assert_order_is_a_permutation(&q);
}
/// Shuffle off: the queue order *is* the play order, so a sort decides what
/// plays next (D10). The playing track is the one the sort puts first, so
/// the whole new order is still ahead of it — with the play order merely
/// remapped (the shuffle-on branch) the tail would come out as
/// ["alpha", "beta"], missing the track that moved behind the cursor.
#[test]
fn sorting_without_shuffle_changes_what_plays_next() {
let mut q = queue_of(vec![
meta("c", "gamma", None),
meta("a", "alpha", None),
meta("b", "beta", None),
]);
assert!(q.set_current_position(1)); // "alpha" is playing
q.sort(QueueSort::Artist, false);
assert_eq!(play_sequence(&mut q), vec!["alpha", "beta", "gamma"]);
}
#[test]
fn sorting_with_shuffle_on_preserves_the_shuffled_play_sequence() {
// Shuffle on: the user asked for a random order, so a sort rewrites
// the display order without touching what plays next (D10).
let mut q = queue_of((0..6).map(track).collect());
q.shuffle_on();
q.next_track();
let expected = {
let mut probe = q.clone();
play_sequence(&mut probe)
};
q.sort(QueueSort::Title, true);
assert_eq!(play_sequence(&mut q), expected);
}
#[test]
fn sorting_with_shuffle_on_still_reorders_the_visible_queue() {
let mut q = queue_of(vec![
meta("c", "gamma", None),
meta("a", "alpha", None),
meta("b", "beta", None),
]);
q.shuffle_on();
q.sort(QueueSort::Title, false);
assert_eq!(titles(&q), vec!["alpha", "beta", "gamma"]);
assert_order_is_a_permutation(&q);
}
#[test]
fn sorting_an_empty_or_single_queue_does_not_panic() {
let mut empty = QueueManager::new();
empty.sort(QueueSort::Artist, false);
assert!(empty.is_empty());
let mut one = queue_of(vec![track(0)]);
one.sort(QueueSort::Reverse, false);
assert_eq!(titles(&one), vec!["track 0"]);
assert_order_is_a_permutation(&one);
}
#[test]
fn an_unspecified_sort_leaves_the_queue_alone() {
let mut q = queue_of(vec![track(1), track(0)]);
q.sort(QueueSort::Unspecified, false);
assert_eq!(titles(&q), vec!["track 1", "track 0"]);
}
} }
/// A command for the provider orchestrator, tagged with the tracing span that /// A command for the provider orchestrator, tagged with the tracing span that
/// was current when it was sent so the handler can attribute its events to /// was current when it was sent so the handler can attribute its events to
@ -1049,6 +1293,23 @@ pub enum PlaybackCommand {
Clear { Clear {
exclude_current: bool, exclude_current: bool,
}, },
/// Drops duplicate queue entries and reports how many went
/// (architecture/queue-order.md D2). Handled on the loop like every other
/// queue mutation; the count travels back through `result_tx` because it
/// cannot be recovered from the resulting snapshot.
DedupQueue {
/// Collapse same artist+title instead of the provably-same item — the
/// aggressive, opt-in identity (architecture/queue-order.md D3a).
by_title: bool,
result_tx: flume::Sender<usize>,
},
/// Reorders the queue by one strategy (architecture/queue-order.md D6).
/// No result channel: the new order is the answer and it goes out on the
/// update stream.
SortQueue {
sort: QueueSort,
descending: bool,
},
SetCurrent { SetCurrent {
position: u32, position: u32,
}, },
@ -1109,6 +1370,8 @@ impl PlaybackCommand {
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk", Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
Self::ResolveFinished { .. } => "resolve_finished", Self::ResolveFinished { .. } => "resolve_finished",
Self::Clear { .. } => "clear", Self::Clear { .. } => "clear",
Self::DedupQueue { .. } => "dedup_queue",
Self::SortQueue { .. } => "sort_queue",
Self::SetCurrent { .. } => "set_current", Self::SetCurrent { .. } => "set_current",
#[cfg(feature = "fs")] #[cfg(feature = "fs")]
Self::SaveQueue { .. } => "save_queue", Self::SaveQueue { .. } => "save_queue",

View File

@ -265,6 +265,50 @@ impl Playback {
} }
} }
PlaybackCommand::DedupQueue {
by_title,
result_tx,
} => {
// Never removes the playing entry (architecture/queue-order.md
// D4), so unlike `Remove` there is no successor to start.
let removed = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
};
let identity = if by_title {
crate::queue_order::Identity::ArtistTitle
} else {
crate::queue_order::Identity::ProviderItem
};
let removed = queue.dedup(identity);
if removed > 0 {
self.broadcast_queue(&queue);
}
removed
};
debug!(removed, by_title, "de-duplicated the queue");
if let Err(err) = result_tx.send(removed) {
// The caller gave up (client gone); the queue is already
// deduped and broadcast, so this is only a lost count.
debug!("dedup result receiver gone: {err}");
}
}
PlaybackCommand::SortQueue { sort, descending } => {
debug!(?sort, descending, "sorting the queue");
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
};
queue.sort(sort, descending);
// The playing track moved; the `Queue` snapshot carries
// `current_position`, so this one broadcast tells clients both
// the new order and where the cursor now sits — the same way
// `Remove` announces a shifted position.
self.broadcast_queue(&queue);
}
PlaybackCommand::SetCurrent { position } => { PlaybackCommand::SetCurrent { position } => {
debug!(position, "jumping to queue position"); debug!(position, "jumping to queue position");
let track = { let track = {
@ -1168,6 +1212,160 @@ mod tests {
assert_eq!(snapshot.tracks.len(), 1); assert_eq!(snapshot.tracks.len(), 1);
} }
/// The queue-order commands are command-level wiring: the ordering itself
/// is pinned in `queue_order` and `QueueManager`, so what these check is
/// that the loop performs them, answers, and broadcasts.
#[tokio::test]
async fn dedup_command_removes_duplicates_and_replies_with_the_count() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
{
let mut queue = playback.queue.lock().expect("queue lock");
let _ = queue.replace_with_tracks(&[track(0), track(1), track(0)]);
}
let mut updates = playback.update_tx.subscribe();
let (result_tx, result_rx) = flume::bounded(1);
playback
.handle_command(PlaybackCommand::DedupQueue {
by_title: false,
result_tx,
})
.await;
assert_eq!(result_rx.recv_async().await.expect("a reply"), 1);
assert_eq!(queue_titles(&playback), vec!["track 0", "track 1"]);
// And the new queue went out on the stream, not just into memory.
match updates.recv().await.expect("a queue update") {
StreamUpdate::Queue(queue) => assert_eq!(queue.tracks.len(), 2),
other => panic!("expected a queue update, got {other:?}"),
}
}
/// A dedup with nothing to do still answers — 0 is the reply that tells a
/// client "no duplicates" (architecture/queue-order.md D2) — and does not
/// spend a broadcast on an unchanged queue.
#[tokio::test]
async fn dedup_command_answers_zero_without_broadcasting() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
fill_queue(&playback, 3);
let mut updates = playback.update_tx.subscribe();
let (result_tx, result_rx) = flume::bounded(1);
playback
.handle_command(PlaybackCommand::DedupQueue {
by_title: false,
result_tx,
})
.await;
assert_eq!(result_rx.recv_async().await.expect("a reply"), 0);
assert!(
updates.try_recv().is_err(),
"an unchanged queue is not worth a broadcast"
);
}
/// A dedup whose caller vanished must not stall or panic the loop: the
/// queue is already changed by the time the count is sent.
#[tokio::test]
async fn dedup_command_survives_a_dropped_result_channel() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
{
let mut queue = playback.queue.lock().expect("queue lock");
let _ = queue.replace_with_tracks(&[track(0), track(0)]);
}
let (result_tx, result_rx) = flume::bounded(1);
drop(result_rx);
playback
.handle_command(PlaybackCommand::DedupQueue {
by_title: false,
result_tx,
})
.await;
assert_eq!(queue_titles(&playback), vec!["track 0"]);
}
#[tokio::test]
async fn sort_command_reorders_the_queue_and_broadcasts_it() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
fill_queue(&playback, 3); // titles ascend: track 0, 1, 2
let mut updates = playback.update_tx.subscribe();
playback
.handle_command(PlaybackCommand::SortQueue {
sort: crabidy_core::proto::crabidy::QueueSort::Reverse,
descending: false,
})
.await;
assert_eq!(
queue_titles(&playback),
vec!["track 2", "track 1", "track 0"]
);
match updates.recv().await.expect("a queue update") {
StreamUpdate::Queue(queue) => {
// The playing track moved with the sort, and the snapshot says
// where it went.
assert_eq!(queue.current_position, 2);
assert_eq!(queue.tracks.len(), 3);
}
other => panic!("expected a queue update, got {other:?}"),
}
}
/// Both operations are queue mutations, so both must reach the persister
/// (architecture/queue-order.md D12) — the same check
/// `queue_mutations_reach_the_persist_channel` makes for `Remove`.
#[cfg(feature = "fs")]
#[tokio::test]
async fn queue_order_commands_reach_the_persist_channel() {
let dir = TempDir::new().expect("tempdir");
let playback = playback_with(Some(store_in(&dir).await));
{
let mut queue = playback.queue.lock().expect("queue lock");
let _ = queue.replace_with_tracks(&[track(0), track(1), track(0)]);
}
let rx = playback.persist_tx.subscribe();
let (result_tx, _result_rx) = flume::bounded(1);
playback
.handle_command(PlaybackCommand::DedupQueue {
by_title: false,
result_tx,
})
.await;
assert_eq!(
rx.borrow().clone().expect("snapshot sent").tracks.len(),
2,
"a dedup must be persisted"
);
playback
.handle_command(PlaybackCommand::SortQueue {
sort: crabidy_core::proto::crabidy::QueueSort::Reverse,
descending: false,
})
.await;
let snapshot = rx.borrow().clone().expect("snapshot sent");
assert_eq!(
snapshot.tracks.first().map(|t| t.title.clone()),
Some("track 1".to_string()),
"a sort must be persisted in its new order"
);
}
/// The gap that let the paste bug through: every previous test drove /// The gap that let the paste bug through: every previous test drove
/// either `insert_tracks` or `PendingResolve` directly, so none of them /// either `insert_tracks` or `PendingResolve` directly, so none of them
/// pinned what the **`Insert` command** does with its position. It is an /// pinned what the **`Insert` command** does with its position. It is an

View File

@ -0,0 +1,545 @@
//! Queue order: the pure part of de-duplication and sorting
//! (architecture/queue-order.md).
//!
//! Everything here is a function of the queue's tracks alone — no locks, no
//! play order, no clients. [`QueueManager`](crate::QueueManager) owns the
//! `play_order`/`current_offset` bookkeeping and calls in here for the
//! decisions: what makes two entries the same track (D3), and what order a
//! strategy puts them in (D6D9).
use std::cmp::Ordering;
use std::collections::HashMap;
use crabidy_core::proto::crabidy::{QueueSort, Track};
/// What identifies a track for de-duplication (D3).
///
/// A provider item id is provider-internal — the content store keys content
/// by `(provider, id)` for the same reason — so it is scoped to the first
/// path segment. Two providers reusing the same numeric id must not merge.
/// An empty id falls back to the whole path, which is exact.
///
/// Cheap to build (two borrows, no allocation) so a dedup pass over a long
/// queue stays a single hash-set walk.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum TrackId<'a> {
/// `(provider, provider_item_id)` — the same item however it was reached
/// (through an album, a playlist, a search result).
ProviderItem { provider: &'a str, id: &'a str },
/// The full library path, for tracks whose provider reports no id.
Path(&'a str),
/// Lowercased artist and title: the same *song*, whatever the recording
/// ([`Identity::ArtistTitle`]).
ArtistTitle(String, String),
}
/// What makes two queue entries "the same track" for de-duplication.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Identity {
/// The provably-same item: the provider's own id under the same provider,
/// or the same path (D3). The default, because it cannot discard a version
/// the user chose.
#[default]
ProviderItem,
/// The same artist and title, compared case-insensitively — a remix, a
/// radio edit and the album version collapse to one entry. Opt-in: it
/// throws away recordings that differ (D3a).
ArtistTitle,
}
/// The de-duplication identity of `track` under `identity` (D3, D3a).
pub fn track_id(track: &Track, identity: Identity) -> TrackId<'_> {
match identity {
Identity::ArtistTitle => TrackId::ArtistTitle(
track.artist.trim().to_lowercase(),
track.title.trim().to_lowercase(),
),
Identity::ProviderItem if track.provider_item_id.is_empty() => TrackId::Path(&track.path),
Identity::ProviderItem => TrackId::ProviderItem {
provider: provider_of(&track.path),
id: &track.provider_item_id,
},
}
}
/// The provider a library path belongs to: its first segment. `""` for a path
/// that has none, which then simply groups those together — they are all one
/// provider's worth of nothing.
fn provider_of(path: &str) -> &str {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or_default()
}
/// The positions to remove so that only one copy of each track remains,
/// ascending (D4).
///
/// The survivor of a group is the entry at `current` when the group contains
/// it, otherwise the earliest entry of the group — so the playing track is
/// never in the returned list and a dedup cannot interrupt playback.
/// `current` being out of range (an empty queue) simply means "no protected
/// entry".
pub fn duplicate_positions(tracks: &[Track], current: usize, identity: Identity) -> Vec<usize> {
let keys: Vec<TrackId<'_>> = tracks.iter().map(|t| track_id(t, identity)).collect();
// The survivor of each group, decided before anything is dropped, so a
// group whose survivor is the playing entry can still drop entries that
// come *before* it (D4).
let mut survivors: HashMap<&TrackId<'_>, usize> = HashMap::new();
for (pos, key) in keys.iter().enumerate() {
let survivor = survivors.entry(key).or_insert(pos);
if keys.get(current) == Some(key) {
// The playing entry always wins its group: a dedup that stops the
// music is a bug, not a policy.
*survivor = current;
} else if identity == Identity::ArtistTitle && *survivor != current {
// Same song, different recordings: keep the longest take — the
// full version rather than a radio edit (D3a). Unknown lengths
// lose, and equal lengths keep the earlier entry.
let better = duration_of(tracks, pos) > duration_of(tracks, *survivor);
if better {
*survivor = pos;
}
}
}
(0..tracks.len())
.filter(|pos| survivors.get(&keys[*pos]) != Some(pos))
.collect()
}
/// The known duration of a queue entry in seconds; `0` when unknown, which
/// makes an unknown length lose the "longest take" comparison above.
fn duration_of(tracks: &[Track], pos: usize) -> u32 {
tracks.get(pos).and_then(|t| t.duration).unwrap_or(0)
}
/// The permutation that `sort` puts `tracks` in: `perm[new] = old`, i.e. the
/// new queue is `perm.iter().map(|&old| tracks[old])`.
///
/// Always a permutation of `0..tracks.len()`, so callers can invert it to
/// remap the play order (D10). Stable: equal keys keep their queue order
/// (D7). `QueueSort::Reverse` ignores `descending` (D6);
/// `QueueSort::Unspecified` is rejected before it reaches here and is treated
/// as the identity.
pub fn sort_permutation(tracks: &[Track], sort: QueueSort, descending: bool) -> Vec<usize> {
let mut perm: Vec<usize> = (0..tracks.len()).collect();
match sort {
// The RPC layer refuses this; a no-op is the safe reading of "no
// strategy named" for any other caller (D6).
QueueSort::Unspecified => perm,
// Not a key: the order the queue is in *is* the input, and there is
// no such thing as reversing it descendingly (D6).
QueueSort::Reverse => {
perm.reverse();
perm
}
keyed => {
// Decoratesortundecorate: one key per track, so a long queue
// lowercases n times rather than n log n (A5). `sort_by` is
// stable, which is what keeps queue order inside an equal key
// (D7).
let keys: Vec<SortKey> = tracks.iter().map(|t| sort_key(t, keyed)).collect();
perm.sort_by(|&a, &b| compare(&keys[a], &keys[b], descending));
perm
}
}
}
/// A track's comparable key under one strategy. `None` components are
/// "unknown" and sort last whichever direction was asked for (D8).
enum SortKey {
/// Text components compared in order — artist, then album.
Text(Vec<Option<String>>),
/// Seconds; `None` for a stream whose length nobody knows.
Number(Option<u32>),
}
/// The key `sort` reads off `track`. Text is trimmed and lowercased once here,
/// never inside a comparison (D9).
fn sort_key(track: &Track, sort: QueueSort) -> SortKey {
fn text(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_lowercase())
}
let album = track.album.as_ref().map(|a| a.title.as_str()).unwrap_or("");
match sort {
QueueSort::Artist => SortKey::Text(vec![text(&track.artist), text(album)]),
QueueSort::Album => SortKey::Text(vec![text(album)]),
QueueSort::Title => SortKey::Text(vec![text(&track.title)]),
QueueSort::Duration => SortKey::Number(track.duration),
// Neither reaches here: both are handled without a key.
QueueSort::Reverse | QueueSort::Unspecified => SortKey::Text(Vec::new()),
}
}
fn compare(a: &SortKey, b: &SortKey, descending: bool) -> Ordering {
match (a, b) {
(SortKey::Text(left), SortKey::Text(right)) => left
.iter()
.zip(right.iter())
.map(|(l, r)| compare_unknown_last(l.as_deref(), r.as_deref(), descending))
.find(|ordering| *ordering != Ordering::Equal)
.unwrap_or(Ordering::Equal),
(SortKey::Number(left), SortKey::Number(right)) => {
compare_unknown_last(*left, *right, descending)
}
// One strategy keys the whole queue, so the variants always match.
_ => Ordering::Equal,
}
}
/// Compares two optional keys with unknown (`None`) always last, and only the
/// *known* values flipped by `descending` (D8). A length-less stream is not
/// the longest track, and a blank album is not alphabetically first.
fn compare_unknown_last<T: Ord>(a: Option<T>, b: Option<T>, descending: bool) -> Ordering {
match (a, b) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) if descending => b.cmp(&a),
(Some(a), Some(b)) => a.cmp(&b),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::Album;
/// A queue entry. `id` empty means the provider reported none.
fn track(path: &str, id: &str) -> Track {
Track {
path: path.to_string(),
artist: "artist".to_string(),
title: "title".to_string(),
duration: None,
album: None,
is_skipped: false,
provider_item_id: id.to_string(),
is_captured: false,
}
}
/// A track with the metadata the sort keys read.
fn meta(artist: &str, album: Option<&str>, title: &str, duration: Option<u32>) -> Track {
Track {
path: format!("/tidal/{artist}/{title}"),
artist: artist.to_string(),
title: title.to_string(),
duration,
album: album.map(|title| Album {
title: title.to_string(),
release_date: None,
}),
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}
}
fn titles(tracks: &[Track], perm: &[usize]) -> Vec<String> {
perm.iter().map(|&i| tracks[i].title.clone()).collect()
}
// ---- identity (D3) ------------------------------------------------
#[test]
fn a_provider_item_id_identifies_a_track_across_paths() {
// The same Tidal track through an album and through a playlist.
let via_album = track("/tidal/albums/7/99", "99");
let via_playlist = track("/tidal/playlists/p/99", "99");
assert_eq!(
track_id(&via_album, Identity::ProviderItem),
track_id(&via_playlist, Identity::ProviderItem)
);
}
#[test]
fn ids_are_scoped_to_their_provider() {
// Numeric ids collide across providers; merging them would drop an
// unrelated track (D3).
let tidal = track("/tidal/albums/7/1234", "1234");
let jamendo = track("/jamendo/tracks/1234", "1234");
assert_ne!(
track_id(&tidal, Identity::ProviderItem),
track_id(&jamendo, Identity::ProviderItem)
);
}
#[test]
fn a_captured_copy_is_not_its_streaming_source() {
// A link file preserves the source id but lives under another
// provider, so it stays a separate entry (D3, stated risk).
let source = track("/tidal/albums/7/99", "99");
let capture = track("/crabidy/mix/song.cbd-track.toml", "99");
assert_ne!(
track_id(&source, Identity::ProviderItem),
track_id(&capture, Identity::ProviderItem)
);
}
#[test]
fn without_an_id_the_path_is_the_identity() {
let a = track("/fs/music/song.flac", "");
let same = track("/fs/music/song.flac", "");
let other = track("/fs/music/other.flac", "");
assert_eq!(
track_id(&a, Identity::ProviderItem),
track_id(&same, Identity::ProviderItem)
);
assert_ne!(
track_id(&a, Identity::ProviderItem),
track_id(&other, Identity::ProviderItem)
);
assert!(matches!(
track_id(&a, Identity::ProviderItem),
TrackId::Path(_)
));
}
// ---- which copies go (D4) -----------------------------------------
#[test]
fn duplicates_after_the_first_are_removed() {
let tracks = [
track("/tidal/a/1", "1"),
track("/tidal/a/2", "2"),
track("/tidal/b/1", "1"),
track("/tidal/a/2", "2"),
];
assert_eq!(
duplicate_positions(&tracks, 0, Identity::ProviderItem),
vec![2, 3]
);
}
#[test]
fn the_playing_entry_survives_its_group() {
// Position 2 is playing and is a duplicate of 0: 0 goes, not 2 —
// a dedup must never stop the music (D4).
let tracks = [
track("/tidal/a/1", "1"),
track("/tidal/a/2", "2"),
track("/tidal/b/1", "1"),
];
assert_eq!(
duplicate_positions(&tracks, 2, Identity::ProviderItem),
vec![0]
);
// With another entry playing, the ordinary rule applies again.
assert_eq!(
duplicate_positions(&tracks, 1, Identity::ProviderItem),
vec![2]
);
}
#[test]
fn nothing_to_do_is_an_empty_list() {
let tracks = [track("/tidal/a/1", "1"), track("/tidal/a/2", "2")];
assert!(duplicate_positions(&tracks, 0, Identity::ProviderItem).is_empty());
assert!(duplicate_positions(&[], 0, Identity::ProviderItem).is_empty());
// An out-of-range current (an empty or freshly cleared queue) is not
// a panic and protects nothing.
assert!(duplicate_positions(&[], 9, Identity::ProviderItem).is_empty());
}
#[test]
fn positions_come_back_ascending_and_are_unique() {
let tracks: Vec<Track> = (0..6).map(|_| track("/tidal/a/1", "1")).collect();
let positions = duplicate_positions(&tracks, 3, Identity::ProviderItem);
let mut sorted = positions.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(positions, sorted, "ascending and without repeats");
// Five of six copies go, and the playing one is not among them.
assert_eq!(positions.len(), 5);
assert!(!positions.contains(&3));
}
// ---- the opt-in title identity (D3a) -------------------------------
/// The case that motivated the mode, from a real queue: two remixes and the
/// album version of one song, three distinct provider items with three
/// different lengths. The default identity keeps all three; the title
/// identity keeps the longest.
#[test]
fn the_title_identity_collapses_different_recordings() {
// Faithful to the queue this came from: distinct ISRC-style ids under
// one provider, three lengths, two of them from the remixes album.
let with_id = |id: &str, title: &str, secs: u32| Track {
provider_item_id: id.to_string(),
path: format!("/crabidy/Mindchatter/{id}.cbd-track.toml"),
..meta("Mindchatter", None, title, Some(secs))
};
let tracks = vec![
with_id("QZES72134712", "Sink Into The Hips", 189),
with_id("QZES72136149", "Sink Into The Hips", 171),
with_id("QZMEM2002285", "Sink Into the Hips", 228),
];
assert!(
duplicate_positions(&tracks, 0, Identity::ProviderItem).is_empty(),
"three distinct items are not duplicates by default"
);
// Nothing playing (an out-of-range current), so the length rule decides:
// case in the title is not a difference, and the 228s take survives.
assert_eq!(
duplicate_positions(&tracks, 9, Identity::ArtistTitle),
vec![0, 1]
);
}
#[test]
fn the_title_identity_keeps_the_playing_entry_over_the_longest() {
let tracks = vec![
meta("artist", None, "song", Some(300)),
meta("artist", None, "song", Some(120)),
];
// Position 1 is playing: it survives even though it is the shorter
// take — playback outranks the length rule (D4).
assert_eq!(
duplicate_positions(&tracks, 1, Identity::ArtistTitle),
vec![0]
);
}
#[test]
fn an_unknown_length_loses_to_a_known_one() {
let tracks = vec![
meta("artist", None, "song", None),
meta("artist", None, "song", Some(120)),
];
assert_eq!(
duplicate_positions(&tracks, 5, Identity::ArtistTitle),
vec![0],
"the entry with a known length survives"
);
}
#[test]
fn the_title_identity_does_not_merge_different_artists() {
let tracks = vec![
meta("Aretha Franklin", None, "Respect", Some(147)),
meta("Otis Redding", None, "Respect", Some(128)),
];
assert!(duplicate_positions(&tracks, 0, Identity::ArtistTitle).is_empty());
}
// ---- sorting (D6D9) ----------------------------------------------
#[test]
fn every_strategy_returns_a_permutation() {
let tracks: Vec<Track> = ["c", "a", "b"]
.iter()
.map(|t| meta("artist", Some("album"), t, None))
.collect();
for sort in [
QueueSort::Artist,
QueueSort::Album,
QueueSort::Title,
QueueSort::Duration,
QueueSort::Reverse,
] {
for descending in [false, true] {
let mut perm = sort_permutation(&tracks, sort, descending);
perm.sort_unstable();
assert_eq!(
perm,
vec![0, 1, 2],
"{sort:?} descending={descending} must be a permutation"
);
}
}
}
#[test]
fn sorting_by_artist_groups_albums_and_keeps_track_order() {
// Two artists, two albums, deliberately interleaved and with the
// album's own tracks out of alphabetical order: stability must keep
// them in queue order inside their album (D7).
let tracks = vec![
meta("Beatles", Some("Revolver"), "Taxman", None),
meta("Abba", Some("Arrival"), "Money", None),
meta("Beatles", Some("Abbey Road"), "Come Together", None),
meta("Beatles", Some("Revolver"), "Eleanor Rigby", None),
];
let perm = sort_permutation(&tracks, QueueSort::Artist, false);
assert_eq!(
titles(&tracks, &perm),
vec!["Money", "Come Together", "Taxman", "Eleanor Rigby"]
);
}
#[test]
fn text_sorts_ignore_case() {
let tracks = vec![
meta("artist", None, "beta", None),
meta("artist", None, "Alpha", None),
meta("artist", None, "gamma", None),
];
let perm = sort_permutation(&tracks, QueueSort::Title, false);
assert_eq!(titles(&tracks, &perm), vec!["Alpha", "beta", "gamma"]);
}
#[test]
fn descending_reverses_the_key_but_not_the_blanks() {
// A missing album sorts last either way (D8).
let tracks = vec![
meta("artist", Some("Bee"), "b", None),
meta("artist", None, "none", None),
meta("artist", Some("Ann"), "a", None),
];
let up = sort_permutation(&tracks, QueueSort::Album, false);
assert_eq!(titles(&tracks, &up), vec!["a", "b", "none"]);
let down = sort_permutation(&tracks, QueueSort::Album, true);
assert_eq!(titles(&tracks, &down), vec!["b", "a", "none"]);
}
#[test]
fn unknown_durations_sort_last_in_both_directions() {
let tracks = vec![
meta("artist", None, "long", Some(300)),
meta("artist", None, "stream", None),
meta("artist", None, "short", Some(100)),
];
let up = sort_permutation(&tracks, QueueSort::Duration, false);
assert_eq!(titles(&tracks, &up), vec!["short", "long", "stream"]);
let down = sort_permutation(&tracks, QueueSort::Duration, true);
assert_eq!(titles(&tracks, &down), vec!["long", "short", "stream"]);
}
#[test]
fn reverse_flips_the_current_order_and_ignores_the_direction() {
let tracks: Vec<Track> = ["one", "two", "three"]
.iter()
.map(|t| meta("artist", None, t, None))
.collect();
for descending in [false, true] {
let perm = sort_permutation(&tracks, QueueSort::Reverse, descending);
assert_eq!(
titles(&tracks, &perm),
vec!["three", "two", "one"],
"reverse has no direction (D6)"
);
}
}
#[test]
fn an_unspecified_strategy_changes_nothing() {
let tracks: Vec<Track> = ["b", "a"]
.iter()
.map(|t| meta("artist", None, t, None))
.collect();
let perm = sort_permutation(&tracks, QueueSort::Unspecified, false);
assert_eq!(
perm,
vec![0, 1],
"the RPC layer rejects it; here it is a no-op"
);
}
#[test]
fn sorting_nothing_does_not_panic() {
assert!(sort_permutation(&[], QueueSort::Artist, false).is_empty());
assert!(sort_permutation(&[], QueueSort::Reverse, false).is_empty());
}
}

View File

@ -5,16 +5,17 @@ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate, crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse, AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest, CreateLibraryNodeRequest, CreateLibraryNodeResponse, DedupQueueRequest, DedupQueueResponse,
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, DeleteLibraryNodeRequest, DeleteLibraryNodeResponse, GetLibraryNodeRequest,
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, GetLibraryNodeResponse, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest, InitResponse, InsertRequest, InsertResponse, NextRequest, NextResponse, PrevRequest,
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest, PrevResponse, QueueRequest, QueueResponse, QueueSort, RemoveRequest, RemoveResponse,
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RenameLibraryNodeRequest, RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse,
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest, SeekResponse, RestartTrackRequest, RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest,
SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest, SeekResponse, SetCurrentRequest, SetCurrentResponse, SortQueueRequest, SortQueueResponse,
ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest,
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest,
ToggleShuffleResponse,
}; };
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::pin::Pin; use std::pin::Pin;
@ -355,6 +356,59 @@ impl CrabidyService for RpcService {
Ok(Response::new(ClearQueueResponse {})) Ok(Response::new(ClearQueueResponse {}))
} }
#[instrument(skip(self, request), fields(by_title))]
async fn dedup_queue(
&self,
request: Request<DedupQueueRequest>,
) -> Result<Response<DedupQueueResponse>, Status> {
let by_title = request.into_inner().by_title;
tracing::Span::current().record("by_title", by_title);
debug!("received dedup_queue request");
// The count comes back from the loop (architecture/queue-order.md D2);
// the dedup itself has already been broadcast by then.
let (result_tx, result_rx) = flume::bounded(1);
self.send_playback(PlaybackCommand::DedupQueue {
by_title,
result_tx,
})
.await?;
let removed = result_rx.recv_async().await.map_err(|err| {
error!("no reply from playback loop: {err}");
Status::internal("playback loop did not reply")
})?;
Ok(Response::new(DedupQueueResponse {
removed: removed as u32,
}))
}
#[instrument(skip(self, request), fields(sort, descending))]
async fn sort_queue(
&self,
request: Request<SortQueueRequest>,
) -> Result<Response<SortQueueResponse>, Status> {
let req = request.into_inner();
tracing::Span::current().record("sort", req.sort);
tracing::Span::current().record("descending", req.descending);
debug!("received sort_queue request");
// An unknown discriminant or the proto3 default are both "the client
// did not name a strategy" — refused rather than silently sorted one
// particular way (architecture/queue-order.md D6).
let sort = match QueueSort::try_from(req.sort) {
Ok(QueueSort::Unspecified) | Err(_) => {
return Err(Status::invalid_argument(
"sort must name a strategy: artist, album, title, duration or reverse",
));
}
Ok(sort) => sort,
};
self.send_playback(PlaybackCommand::SortQueue {
sort,
descending: req.descending,
})
.await?;
Ok(Response::new(SortQueueResponse {}))
}
#[instrument(skip(self, request), fields(position))] #[instrument(skip(self, request), fields(position))]
async fn set_current( async fn set_current(
&self, &self,
@ -651,4 +705,76 @@ mod tests {
} }
} }
} }
/// A client that names no strategy — or an enum value this server does not
/// know — is refused, never silently sorted one particular way
/// (architecture/queue-order.md D6). Nothing must reach the loop.
#[tokio::test]
async fn sort_queue_refuses_an_unnamed_strategy() {
let (service, playback_rx) = service();
for sort in [0, 99, -1] {
let status = service
.sort_queue(Request::new(SortQueueRequest {
sort,
descending: false,
}))
.await
.expect_err("must be refused");
assert_eq!(status.code(), tonic::Code::InvalidArgument, "sort = {sort}");
}
assert!(
playback_rx.try_recv().is_err(),
"a refused sort must not reach the playback loop"
);
}
#[tokio::test]
async fn sort_queue_forwards_the_strategy_and_direction() {
let (service, playback_rx) = service();
service
.sort_queue(Request::new(SortQueueRequest {
sort: QueueSort::Duration as i32,
descending: true,
}))
.await
.expect("accepted");
match playback_rx
.recv_async()
.await
.expect("command sent")
.command
{
PlaybackCommand::SortQueue { sort, descending } => {
assert_eq!(sort, QueueSort::Duration);
assert!(descending);
}
other => panic!("expected a sort command, got {}", other.name()),
}
}
/// The dedup count is produced on the loop and handed back verbatim.
#[tokio::test]
async fn dedup_queue_returns_the_loops_count() {
let (service, playback_rx) = service();
// Answer the command as the loop would, then read the response.
let responder = tokio::spawn(async move {
match playback_rx
.recv_async()
.await
.expect("command sent")
.command
{
PlaybackCommand::DedupQueue { result_tx, .. } => {
result_tx.send(7).expect("reply accepted");
}
other => panic!("expected a dedup command, got {}", other.name()),
}
});
let response = service
.dedup_queue(Request::new(DedupQueueRequest { by_title: false }))
.await
.expect("accepted");
assert_eq!(response.into_inner().removed, 7);
responder.await.expect("responder");
}
} }

View File

@ -102,12 +102,112 @@ fn log_bin_edges(mag_len: usize, bins: usize) -> Vec<usize> {
.collect() .collect()
} }
/// How many consecutive ticks may see no new samples before the bars are
/// declared idle and dropped to zero.
///
/// One is too few. The tap counts *frames*, so during playback every tick
/// normally sees thousands more — but a tick that arrives early (or two that
/// arrive together) sees none, and calling that silence made the bars flick to
/// the floor about once a second and wrote two log lines each time. Two ticks
/// is 100 ms at 20 fps: still immediate to the eye when playback really stops.
const IDLE_TICKS_BEFORE_ZERO: u8 = 2;
/// Decides, tick by tick, whether audio is flowing — the only state the
/// spectrum task keeps.
///
/// Split out from the task so the flapping that motivated it is testable
/// without a runtime or an audio device (architecture/spectrum.md D2).
pub struct FlowDetector {
last_count: u64,
idle_ticks: u8,
active: bool,
}
/// What the spectrum task should send after one tick.
#[derive(Debug, PartialEq, Eq)]
pub enum Tick {
/// Audio is flowing: analyze the window and broadcast bars.
Bars,
/// Playback just went idle: broadcast a single zeroed frame so the bars
/// fall instead of freezing.
Silence,
/// Nothing to say.
Nothing,
}
impl FlowDetector {
pub fn new(count: u64) -> Self {
Self {
last_count: count,
idle_ticks: 0,
active: false,
}
}
/// Folds this tick's frame count into the decision.
pub fn observe(&mut self, count: u64) -> Tick {
if count != self.last_count {
self.last_count = count;
self.idle_ticks = 0;
self.active = true;
return Tick::Bars;
}
// No new frames. Only after enough of them in a row is this silence
// rather than a tick that merely landed between two callbacks.
self.idle_ticks = self.idle_ticks.saturating_add(1);
if self.active && self.idle_ticks >= IDLE_TICKS_BEFORE_ZERO {
self.active = false;
return Tick::Silence;
}
Tick::Nothing
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
const WINDOW: usize = 2048; const WINDOW: usize = 2048;
/// The flapping this exists to stop: a single tick with no new frames is
/// not silence, so it must not zero the bars (which is what the log showed
/// happening ~1/s during playback).
#[test]
fn one_empty_tick_does_not_zero_the_bars() {
let mut flow = FlowDetector::new(0);
assert_eq!(flow.observe(1000), Tick::Bars);
assert_eq!(
flow.observe(1000),
Tick::Nothing,
"one empty tick is not silence"
);
assert_eq!(flow.observe(2000), Tick::Bars);
}
#[test]
fn sustained_silence_zeroes_the_bars_exactly_once() {
let mut flow = FlowDetector::new(0);
assert_eq!(flow.observe(1000), Tick::Bars);
assert_eq!(flow.observe(1000), Tick::Nothing);
assert_eq!(flow.observe(1000), Tick::Silence);
// And then stays quiet rather than re-sending zero frames forever.
for _ in 0..5 {
assert_eq!(flow.observe(1000), Tick::Nothing);
}
// Resuming reports flowing again.
assert_eq!(flow.observe(1001), Tick::Bars);
}
/// A player that never started must not emit a zero frame just because
/// nothing is happening.
#[test]
fn an_idle_player_says_nothing() {
let mut flow = FlowDetector::new(0);
for _ in 0..10 {
assert_eq!(flow.observe(0), Tick::Nothing);
}
}
fn sine(freq: f32, sample_rate: f32, len: usize) -> Vec<f32> { fn sine(freq: f32, sample_rate: f32, len: usize) -> Vec<f32> {
(0..len) (0..len)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin()) .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())

View File

@ -47,6 +47,14 @@ cbd global volume -- -0.1 # lower the volume
`replace <PATH>…`, `queue remove <POS>…`, `queue clear `replace <PATH>…`, `queue remove <POS>…`, `queue clear
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture [--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
<NAME>`, `queue shuffle`, and `queue repeat` change it. <NAME>`, `queue shuffle`, and `queue repeat` change it.
- `queue dedup` drops duplicate entries and prints how many went (`0` when
there were none); `--titles` switches to the blunter same-song identity
(keeping the longest take, discarding remixes and edits);
`queue sort <artist|album|title|duration|reverse> [--desc]` reorders it.
Both are described under [Order operations: dedup and
sort](../queue.md#order-operations-dedup-and-sort) — the strategy is a
fixed choice, so a shell completes it and a typo fails before anything
reaches the server.
- `global play`/`stop`/`next`/`prev`/`restart`/`mute`, `global - `global play`/`stop`/`next`/`prev`/`restart`/`mute`, `global
volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g. volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g.
`global seek -15`) control playback. `global seek -15`) control playback.

View File

@ -307,6 +307,9 @@ pane).
| Queue | `d` | Remove selection (into the register) | | Queue | `d` | Remove selection (into the register) |
| Queue | `c` | Clear queue except current (to register) | | Queue | `c` | Clear queue except current (to register) |
| Queue | `C` | Clear entire queue (to register) | | Queue | `C` | Clear entire queue (to register) |
| Queue | `u` | Unique: drop duplicate tracks |
| Queue | `U` | Unique by title: one entry per song |
| Queue | `S` | Sort the queue (opens a strategy menu) |
| Queue | `w` | Save queue under a name | | Queue | `w` | Save queue under a name |
| Queue | `W` | Capture the queue into /crabidy (audio) | | Queue | `W` | Capture the queue into /crabidy (audio) |
| Queue | `/` | Filter this view | | Queue | `/` | Filter this view |

View File

@ -158,6 +158,70 @@ Two consequences worth knowing:
node expands it to tracks at paste time, and a path that no longer node expands it to tracks at paste time, and a path that no longer
resolves simply does not come back. resolves simply does not come back.
## Order operations: dedup and sort
Two verbs rewrite the **order** of what is already in the queue, without
re-resolving anything through a provider: `DedupQueue` and `SortQueue`
(`architecture/queue-order.md`). Both run on the playback loop, so they are
atomic against resolve chunks and other clients, and both are persisted and
pushed to every client like any other queue change.
**Dedup** (`u` in the queue pane, `cbd queue dedup`) keeps one copy of each
track. Two entries are the same track when they carry the same
`provider_item_id` *under the same provider*, or — when the provider reports
no id — the same library path. It is deliberately not metadata matching:
identical artist and title is routinely a different recording (a live take, a
remaster, a radio edit), and dropping one would be an unrecoverable edit of
your queue. Two consequences:
- A captured copy under `/crabidy` and its streaming original are **not**
duplicates. Different providers, and the capture exists to be its own entry.
- The survivor of a group is the **playing** entry if it is one of them,
otherwise the earliest. A dedup can never interrupt playback.
The RPC answers with the number of entries removed; `0` is a real answer, and
the clients show it ("no duplicates") rather than nothing.
```admonish warning title="Dedup by title"
The same verb with a blunter identity: **same artist and title**, whatever the
recording, keeping the longest take (still yielding to the playing entry). One
entry per *song* rather than per recording — which means it discards remixes,
live takes and radio edits you may have queued on purpose. It is on its own key
for that reason (`U` in either client, `queue dedup --titles` on the command
line); `u` never does this.
```
**Sort** (`S` opens a menu in either client, `cbd queue sort <key> [--desc]`)
reorders by one strategy:
| Strategy | Key in the menu | Orders by |
| --- | --- | --- |
| artist | `a` | artist, then album |
| album | `l` | album title |
| title | `t` | track title |
| duration | `d` | length |
| reverse | `r` | the current order, flipped |
The capital of each letter sorts descending (`reverse` has no direction). Every
sort is **stable**, so entries with an equal key keep their queue order — which
for a queued album is its track order, and which lets two sorts compose: sort
by title, then by artist, and each artist's tracks stay title-sorted. Text
compares case-insensitively and without collation ("The Beatles" sorts under
T); blank text and unknown durations sort **last** in both directions, because
a length-less stream is not the longest track.
```admonish note
Sorting interacts with the shuffle modifier: with shuffle **off** the queue
order is the play order, so a sort changes what plays next. With shuffle
**on** the shuffled sequence — and your position in it — is preserved, and the
sort only changes the order clients display. The playing track keeps playing
either way, at its new position.
```
Both operations apply to what the queue holds at that moment. Tracks still
being resolved (the loading indicator) land afterwards, at the end or at their
insert index, unsorted — press again once the queue settles.
## Seeking inside a track ## Seeking inside a track
`,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing `,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing

137
plan/queue-order.md Normal file
View File

@ -0,0 +1,137 @@
# Plan: queue de-duplication and sorting
Executes `architecture/queue-order.md` against the gates in
`quality/queue-order.md`. The wire and the stubs already exist (api-design);
what follows fills them in, in dependency order. Every task names how it is
verified.
## 1. The wire
- [x] **T1 — Proto: `DedupQueue`, `SortQueue`, `QueueSort`.** Two RPCs, the
strategy enum, `DedupQueueResponse.removed`, with the semantics documented at
the wire. *Verified by:* it compiles through `tonic-prost-build`; G21 by
reading.
- [x] **T2 — Rights matrix.** Both methods as `QueueOwner`, added to the pinned
method list. *Verified by:* `auth.rs::the_method_table_pins_every_rpc_of_the_service`
(G11).
## 2. Pure order logic (`crabidy-server/src/queue_order.rs`)
- [x] **T3 — `track_id`.** Provider-scoped item id, else the whole path.
*Verified by:* `a_provider_item_id_identifies_a_track_across_paths`,
`ids_are_scoped_to_their_provider`, `a_captured_copy_is_not_its_streaming_source`,
`without_an_id_the_path_is_the_identity` (G2).
- [x] **T4 — `duplicate_positions`.** Survivor = current, else earliest;
ascending, unique, never the current position; safe on an empty queue and an
out-of-range current. *Verified by:*
`duplicates_after_the_first_are_removed`, `the_playing_entry_survives_its_group`,
`nothing_to_do_is_an_empty_list`, `positions_come_back_ascending_and_are_unique`
(G1).
- [x] **T5 — `sort_permutation`.** Decoratesortundecorate with keys built
once; compound artist key; case-insensitive text; unknown-last in both
directions; `Reverse` ignores the direction; `Unspecified` is the identity.
*Verified by:* `every_strategy_returns_a_permutation`,
`sorting_by_artist_groups_albums_and_keeps_track_order`, `text_sorts_ignore_case`,
`descending_reverses_the_key_but_not_the_blanks`,
`unknown_durations_sort_last_in_both_directions`,
`reverse_flips_the_current_order_and_ignores_the_direction`,
`an_unspecified_strategy_changes_nothing`, `sorting_nothing_does_not_panic`
(G3, G4, G5).
## 3. Queue state (`crabidy-server/src/lib.rs`)
- [x] **T6 — `QueueManager::dedup`.** Delegates the removal to
`remove_tracks`; returns the count. *Verified by:*
`dedup_removes_later_copies_and_reports_the_count`,
`dedup_keeps_the_playing_track_playing`, `dedup_of_a_clean_queue_changes_nothing`,
`dedup_leaves_the_queue_playable` (G1, G6, G7).
- [x] **T7 — `QueueManager::sort`.** Permute `tracks`; shuffle off ⇒ identity
play order with `current_offset` on the current track, shuffle on ⇒ remap.
*Verified by:* `sorting_reorders_the_queue_and_follows_the_current_track`,
`sorting_without_shuffle_changes_what_plays_next`,
`sorting_with_shuffle_on_preserves_the_shuffled_play_sequence`,
`sorting_with_shuffle_on_still_reorders_the_visible_queue`,
`sorting_an_empty_or_single_queue_does_not_panic`,
`an_unspecified_sort_leaves_the_queue_alone` (G6, G5).
## 4. Server plumbing
- [x] **T8 — Playback loop arms.** Lock, mutate, broadcast; dedup replies with
its count and skips the broadcast when it removed nothing. *Verified by:*
`dedup_command_removes_duplicates_and_replies_with_the_count`,
`dedup_command_answers_zero_without_broadcasting`,
`dedup_command_survives_a_dropped_result_channel`,
`sort_command_reorders_the_queue_and_broadcasts_it`,
`queue_order_commands_reach_the_persist_channel` (G8, G9, G10, G12).
- [x] **T9 — RPC handlers.** `Unspecified`/unknown → `InvalidArgument` before
the loop sees it; the count passes through verbatim. *Verified by:*
`sort_queue_refuses_an_unnamed_strategy`, `sort_queue_forwards_the_strategy_and_direction`,
`dedup_queue_returns_the_loops_count` (G10, G12).
## 5. TUI
- [x] **T10 — Client RPC methods** (`dedup_queue`, `sort_queue`) and the
`MessageFromUi`/`MessageToUi` variants. *Verified by:* compiles; exercised
through the dispatch tests.
- [x] **T11 — Bindings.** `u` dedup, `S` sort menu, queue scope only.
*Verified by:* `queue_order_keys_are_bound_in_the_queue_only`,
`chords_are_unique_within_scope` (G17).
- [x] **T12 — `sort::choose` + the menu table.** Lowercase ascending,
uppercase descending, reverse case-insensitive, unclaimed keys unclaimed.
*Verified by:* `a_lowercase_key_sorts_ascending_and_its_capital_descending`,
`reverse_ignores_the_case`, `keys_the_menu_does_not_offer_are_not_claimed`,
`the_table_is_consistent` (G15).
- [x] **T13 — `sort::render` + `popup_area`.** Centered, `Clear`-backed,
clamped to the frame. *Verified by:*
`the_overlay_lists_every_strategy_with_its_key`, `a_tiny_frame_clamps_the_popup`.
- [x] **T14 — `App::handle_sort_key`.** Modal: sort-and-close, `Esc`/`q`/`S`
close, anything else ignored. *Verified by:*
`a_sort_menu_key_sends_the_strategy_and_closes_the_menu`,
`escape_closes_the_sort_menu_without_sorting`,
`an_unknown_key_leaves_the_sort_menu_open` (G15).
- [x] **T15 — Dispatch + event-loop routing + render hook.** Empty-queue
guards on both actions; the menu is checked before the bindings table.
*Verified by:* `dedup_asks_the_server_only_with_a_non_empty_queue`,
`the_sort_menu_opens_only_with_a_non_empty_queue`.
- [x] **T16 — The pane's dedup notice.** `show_dedup_result` + `notice()`
expiry + title precedence (visual > filter > notice > register).
*Verified by:* `the_dedup_result_appears_in_the_title`,
`the_dedup_result_expires`, `an_active_search_outranks_the_dedup_result`
(G18).
## 6. CLI
- [x] **T17 — `queue dedup` / `queue sort <key> [--desc]`.** `ValueEnum`
strategy. *Verified by:* `queue_dedup_takes_no_arguments`,
`queue_sort_parses_a_strategy_and_an_optional_direction` (G19).
- [x] **T18 — `wire_sort` / `sort_label` and the printed results.** Dedup
prints its count including `0`. *Verified by:* the parse tests plus reading
(G18, G19).
## 7. Web client
- [x] **T19 — RPC methods, `Dialog::Sort`, actions, keymap entries, toolbar
buttons, key routing.** *Verified by:* `queue_order_keys_match_the_tui`,
`the_sort_table_is_consistent` (G16, G17).
- [x] **T20 — `sort_menu_key`.** The web port of `sort::choose`, plus the
close keys. *Verified by:*
`the_sort_menu_maps_keys_to_strategies_and_closes_on_escape` (G15, G17).
- [x] **T21 — `SortMenu` component + `notify`.** Rows clickable and typeable,
dedup count in a toast. *Verified by:* the wasm build (`build-web`) plus
reading (G16, G18).
- [x] **T22 — Web help overlay entries** for `u` and `S`. *Verified by:*
`every_action_reachable_from_help_table` plus reading (G20).
## 8. Documentation
- [x] **T23 — `docs/src/queue.md`**: the two order operations in the queue
model, the duplicate rule, the strategies, and the shuffle interaction.
- [x] **T24 — `docs/src/clients/tui.md`** key table and
**`docs/src/clients/cli.md`** command list. *Verified by:* reading; the book
builds (G20).
## 9. Close-out
- [x] **T25 — Full check.** `cargo test` (workspace), `cargo clippy
--all-targets -D warnings`, `cargo fmt --check`, `cargo test -p cbd-tui
--no-default-features`, and the web build. Then `plan/summary.md`.

View File

@ -1920,3 +1920,104 @@ The first probe run hung, and the cause was my probe: blocking
zbus dispatches on. The shipped binaries are multi-threaded, and the UI runs zbus dispatches on. The shipped binaries are multi-threaded, and the UI runs
on `spawn_blocking`, so the shape is fine — but it is a real trap for anyone on `spawn_blocking`, so the shape is fine — but it is a real trap for anyone
writing another bus test. writing another bus test.
## Queue order: dedup and sorting (2026-07-29)
Two verbs that rewrite what is already in the queue rather than adding to it:
`DedupQueue` and `SortQueue`, server-side, for every client
(`architecture/queue-order.md`). Both run on the playback loop — the single
writer — so they are atomic against resolve chunks and other clients, need no
re-resolution through providers, and reach the persister and the update stream
through the one broadcast site that already existed. Doing either client-side
was the tempting shortcut and the wrong one: a client-computed `Remove` races
the resolve stream, and "sorting" by re-sending paths as a `Replace` would
re-fetch every path and restart playback at the head.
The whole design lives in two questions. **What is a duplicate?** Not
artist+title — identical metadata is routinely a different recording (live,
remaster, radio edit), and a wrong merge is unrecoverable queue state, so the
key is the provider item id *scoped to its provider* (the content store keys
content the same way, so unscoped ids would let two providers' numeric ids
collide), falling back to the whole path when a provider reports no id. The
honest cost, written down rather than discovered: a captured `/crabidy` copy
and its streaming original are not duplicates. **Which copy survives?** The
playing one if it is in the group, otherwise the earliest — "keep the first"
alone stops the music whenever the playing copy is a later one, so the dedup
never removes the current entry and delegates the removal to `remove_tracks`,
leaving exactly one code path that maintains `play_order`.
Sorting's subtlety is the two orders. `tracks` is what clients render and what
playback follows with shuffle off; `play_order` is the permutation shuffle
owns. So a sort permutes `tracks` and then, with shuffle **off**, rebuilds
`play_order` as the identity with `current_offset` on the current track (the
sort decides what plays next — the point of sorting), and with shuffle **on**
remaps `play_order` through the permutation instead, preserving the shuffled
sequence and the position in it (the user asked for random; sorting the display
must not silently reshuffle). Both branches keep the playing track playing at
its new position. The test that pinned this only pinned it after I fixed the
*test*: my first fixture put the current track last after sorting, so the
"what plays next" assertion was asserting nothing.
Five strategies (artist→album, album, title, duration, reverse) with a
`descending` flag, stable so equal keys keep queue order — which for a queued
album is its track order, and which makes two sorts compose. Keys are built
once per track (decoratesortundecorate), not inside the comparator, and one
rule covers every blank: unknown sorts **last** in both directions, because a
length-less stream is not the longest track. `UNSPECIFIED` on the wire is
`InvalidArgument`, never a silent default.
`DedupQueue` is the one queue verb that answers with data — a count — because
`0` cannot be recovered from the new snapshot and is exactly what the user
needs to hear: *there were no duplicates*, not *nothing happened*. It crosses
as a number; the wording is each client's (the TUI shows "no duplicates" in
the queue pane title for four seconds, the web client toasts it). Clients get
`u` (unique) and `S` (a modal strategy menu, unclaimed keys ignored so a stray
`c` cannot clear the queue while the user is choosing), the web client the same
keys plus toolbar buttons driving the same menu, and the CLI `queue dedup` /
`queue sort <key> [--desc]` with the strategy as a `ValueEnum` so a typo fails
before a round trip. 556 workspace tests green, clippy clean, the wasm bundle
and the book build.
## Queue order, second pass: what a real queue and a real log said (2026-07-29)
Four things, all found by measuring rather than reasoning.
**Dedup by title (D3a).** Tried against a live 121-entry queue, the
provider-item identity removed *nothing* — 121 entries, 121 distinct ids —
while the queue plainly held three "Sink Into The Hips". Reading the entries
settled it: distinct ISRC-style ids, 171/189/228 s, two from the remixes album
and one from the album. Different recordings, which the default is right to
keep. Across the queue, 13 same-title groups (19 droppable entries) and *every*
group differed by tens of seconds, which also killed the duration-tolerant
middle option: any tolerance narrow enough to be safe caught nothing. So the
blunt identity ships as an opt-in flag with its own key (`U`,
`queue dedup --titles`), lowercased (artist, title), survivor = the playing
entry else the longest take.
**The screen artefact was stderr.** `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. Its `underrun occurred` printed onto the interface, scrolled
the terminal a line, and since ratatui repaints only changed cells the shift
persisted: the queue looked like it had bled into the now-playing pane. The
message never reached the log either, which is why the log had no underrun in
it. One `dup2` before the alternate screen sends fd 2 to `cbd.stderr.log`, so
the diagnostics are kept off-screen rather than lost.
**A metronomic spectrum flap.** 3664 `audio idle``audio flowing` pairs in one
day's log, ~1 s apart *during* playback. Not device starvation — the regularity
was the tell. tokio's default `MissedTickBehavior::Burst` keeps the absolute
schedule, so once the per-tick FFT lateness accumulates to one whole period, two
ticks fire back to back and the second one necessarily sees no new frames; the
zero-tolerance idle check read that as silence and zeroed the bars. `Delay` plus
a two-tick `FlowDetector` fixes both the flicker and the log noise.
**The log's only ERROR was a shutdown race.** "request to server failed: sending
on a closed channel" was the orchestrator sending to the *UI* channel after the
UI thread exited — a clean quit, reported as a server failure, followed by a
loop spinning on a stream nobody was reading. It now reports the UI closing at
info and returns.
Not done, deliberately: the two `unwrap()`s on `event::poll`/`event::read` still
panic the UI thread on a terminal read error, and that path leaves the terminal
in raw mode. Left out of this pass by choice; it wants a panic hook that
restores the terminal first.

124
quality/queue-order.md Normal file
View File

@ -0,0 +1,124 @@
# Quality gates: queue de-duplication and sorting
Criteria an implementation of `architecture/queue-order.md` must satisfy.
Automatic tests cover the behaviour (see "Tests" at the end); the gates here
are the things a reader has to check by reading the code. Each is pass/fail.
## Correctness of the operations
- [ ] **G1 — Dedup never removes the playing entry.** In every group of
duplicates the survivor is the entry at the current position when it is in
the group, otherwise the earliest one (D4). Check the survivor selection
directly, not only through a test: a "keep the first" shortcut is a silent
playback stop the moment the playing copy is a later one.
- [ ] **G2 — The default duplicate identity is provider-scoped.** The key is
`(first path segment, provider_item_id)` when the id is non-empty and the
whole path otherwise (D3). Artist/title matching exists **only** under the
opt-in `by_title` identity (D3a), never on the default path, and the flag is
never defaulted to true anywhere between key and wire.
- [ ] **G2a — `by_title` keeps the longest take** and still yields to the
playing entry (D3a), and every client surface that reaches it is distinct
from the safe one (its own key, its own flag) — a user cannot get it by
mistyping.
- [ ] **G3 — Sort is stable and total.** `sort_permutation` returns a
permutation of `0..len` for every strategy and direction, equal keys keep
their queue order, and blanks/unknown durations sort last in *both*
directions (D7, D8). No comparator may panic (no `unwrap` on a partial
comparison, no float keys).
- [ ] **G4 — Sort keys are computed once per track.** Decoratesortundecorate,
not a comparator that lowercases inside the comparison (A5). A ten-thousand
entry queue must not allocate per comparison.
- [ ] **G5 — Reverse ignores the direction flag** (D6), and
`QueueSort::Unspecified` reaching `QueueManager::sort` is a no-op rather
than an arbitrary default.
- [ ] **G6 — The play-order invariant holds after both operations.**
`play_order` indexes every track exactly once and `current_offset` points
into it (the invariant `shuffle_insert_keeps_order_unique` guards for
inserts). Shuffle off ⇒ rebuilt from the new order; shuffle on ⇒ remapped
through the permutation, so the remaining play sequence is unchanged (D10).
- [ ] **G7 — Dedup goes through `remove_tracks`.** There is exactly one place
that removes queue entries (D5). A second removal implementation that
maintains `play_order` itself is a fail even if its tests pass.
## Server discipline
- [ ] **G8 — Both operations run on the playback loop and nowhere else.** No
queue mutation outside a `PlaybackCommand` arm; the queue lock is never held
across an `await` (the pattern every existing arm follows).
- [ ] **G9 — One broadcast site.** Both go out through `broadcast_queue`, so
the persister and the update stream cannot drift (D12). A dedup that removed
nothing broadcasts nothing.
- [ ] **G10 — No panics on client input.** `SortQueue` with an unset or
unknown enum value answers `InvalidArgument`; `DedupQueue` with a dropped
result receiver logs and continues; an empty queue is a no-op for both. No
`unwrap`/`expect`/indexing that a client can reach.
- [ ] **G11 — Both RPCs are in the rights matrix as `QueueOwner`** (D1), and
the pinned method-list test in `auth.rs` includes them. A new RPC must not
be able to reach the wire unmapped.
- [ ] **G12 — The dedup count crosses the boundary as a number.** No
server-formatted human string on the wire or in a `MessageToUi` (D2, D15);
wording belongs to each client.
- [ ] **G13 — Nothing is logged that could carry a credential.** Sort logs the
strategy, dedup logs a count. No track paths, no URLs — a queue entry's path
can be a `/rss` item whose URL is a per-subscriber token.
## Clients
- [ ] **G14 — No client computes an order.** No comparators, no
duplicate-finding, and no `Remove`-derived dedup in `cbd-tui`, `cbd-web` or
`cbd-cli` (A1, Option A rejected). Clients send the verb and render what
comes back.
- [ ] **G15 — The sort menu is strictly modal.** While it is open the bindings
table is unreachable and an unclaimed key leaves it open rather than falling
through — a stray `c` must not clear the queue while the user thinks they are
picking a sort (D14).
- [ ] **G16 — Both operations are reachable without a keyboard in the web
client** (D16): toolbar buttons dispatching the same actions as `u` and `S`,
and the strategy list exists once (`SORT_CHOICES`), driving both the overlay
and the button.
- [ ] **G17 — The TUI and web keymaps agree**: `u` = dedup, `S` = sort menu,
and the same five menu letters with the same uppercase-is-descending rule.
Where they differ, the difference is deliberate and documented.
- [ ] **G18 — Feedback exists for both outcomes of a dedup.** `0` renders as
plainly as `7` (D2/D15) — a dedup that found nothing must not look like a
dropped keypress.
- [ ] **G19 — The CLI strategy is a `ValueEnum`** (D17), so a typo is a parse
error, the shell completes it, and the printed confirmation names the
strategy in the same spelling the user typed.
- [ ] **G20 — Every new binding is documented where the others are**: the TUI
help modal (derived from `BINDINGS`, so automatic), the web help overlay
table, `docs/src/clients/tui.md`, `docs/src/clients/cli.md`, and
`docs/src/queue.md`'s queue-operation list.
## Documentation and honesty
- [ ] **G21 — The proto documents the semantics at the wire.** What a
duplicate is, which copy survives, that unknowns sort last, that `Reverse`
ignores `descending`, and that `UNSPECIFIED` is refused.
- [ ] **G22 — The known gaps are written down, not left to be discovered**: a
captured copy and its streaming source are not duplicates (D3), a sort with
shuffle on does not change what plays next (D10), and tracks still resolving
land unsorted after the fact (D11).
- [ ] **G23 — Public items have doc comments** stating intent and edge
behaviour, in the register the surrounding code uses (why, not what).
## Tests
Behaviour is pinned by these, all of which must pass:
| Area | Test home | Pins |
| --- | --- | --- |
| Identity, survivors, permutations | `queue_order.rs` | D3, D4, D6D9 |
| Play-order invariants | `lib.rs` (`QueueManager`) | D5, D10, empty queues |
| Wiring, broadcast, persistence | `playback.rs` | D2, D9, D12 |
| Argument validation | `rpc.rs` | D6, the count |
| Rights matrix | `auth.rs` (existing pinned test) | D1 |
| Keys and modality | `cbd-tui/src/app/{bindings,sort,mod}.rs` | D14 |
| Pane feedback | `cbd-tui/src/app/queue.rs` | D15 |
| Command parsing | `cbd-cli/src/lib.rs` | D17 |
| Keymap parity | `cbd-web/src/keymap.rs` | D16, D17 |
The server rows live under `crabidy-server/src/`.
Plus the repository's standing gates: `cargo clippy` clean, `cargo fmt`,
no new `unwrap` on client-reachable paths, and the book building.