Compare commits
3 Commits
1228c7cb70
...
1f41c45b95
| Author | SHA1 | Date |
|---|---|---|
|
|
1f41c45b95 | |
|
|
bb084dd62b | |
|
|
001abcc35c |
|
|
@ -706,6 +706,7 @@ dependencies = [
|
|||
"crossterm",
|
||||
"dirs",
|
||||
"flume",
|
||||
"libc",
|
||||
"mpris-server",
|
||||
"notify-rust",
|
||||
"ratatui",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ futures = "0.3"
|
|||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
http = "1"
|
||||
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"] }
|
||||
notify-rust = "4"
|
||||
# The MPRIS D-Bus surface of cbd-tui. Built on zbus, which speaks the
|
||||
|
|
|
|||
|
|
@ -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 (decorate–sort–undecorate, 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
|
||||
lock–mutate–broadcast 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.
|
||||
|
|
@ -17,14 +17,15 @@ use tonic::{Request, Status};
|
|||
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
|
||||
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest,
|
||||
SaveQueueRequest, SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest,
|
||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, Track,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
|
||||
DeleteLibraryNodeRequest, GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode,
|
||||
NextRequest, PrevRequest, Queue, QueueSort, RemoveRequest, RenameLibraryNodeRequest,
|
||||
ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest,
|
||||
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`
|
||||
/// captures it (architecture/crabidy-store.md).
|
||||
|
|
@ -167,6 +168,29 @@ async fn run_library(
|
|||
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>> {
|
||||
match cmd {
|
||||
QueueCmd::Show => {
|
||||
|
|
@ -217,6 +241,34 @@ async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std
|
|||
.map_err(rpc_error)?;
|
||||
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 } => {
|
||||
client
|
||||
.set_current(SetCurrentRequest { position })
|
||||
|
|
|
|||
|
|
@ -70,6 +70,23 @@ pub enum LibraryCmd {
|
|||
}
|
||||
|
||||
/// 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)]
|
||||
pub enum QueueCmd {
|
||||
/// Print the current queue.
|
||||
|
|
@ -88,6 +105,24 @@ pub enum QueueCmd {
|
|||
#[arg(long)]
|
||||
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.
|
||||
SetCurrent { position: u32 },
|
||||
/// 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]
|
||||
fn global_volume_parses_a_signed_delta() {
|
||||
let cli =
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ clap.workspace = true
|
|||
dirs.workspace = true
|
||||
toml.workspace = true
|
||||
flume.workspace = true
|
||||
libc.workspace = true
|
||||
mpris-server = { workspace = true, optional = true }
|
||||
notify-rust = { workspace = true, optional = true }
|
||||
ratatui.workspace = true
|
||||
|
|
|
|||
|
|
@ -122,6 +122,19 @@ pub enum Action {
|
|||
QueueRemoveTrack,
|
||||
QueueClearKeepCurrent,
|
||||
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
|
||||
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
|
||||
QueueSaveAs,
|
||||
|
|
@ -552,6 +565,27 @@ pub const BINDINGS: &[Binding] = &[
|
|||
action: Action::QueueClearAll,
|
||||
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 {
|
||||
scope: Scope::Queue,
|
||||
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]
|
||||
fn key_labels_are_human_readable() {
|
||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mod list;
|
|||
mod now_playing;
|
||||
mod queue;
|
||||
mod register;
|
||||
mod sort;
|
||||
|
||||
use flume::Sender;
|
||||
use ratatui::{
|
||||
|
|
@ -16,7 +17,7 @@ use ratatui::{
|
|||
|
||||
use crabidy_core::proto::crabidy::{
|
||||
get_update_stream_response::Update as StreamUpdate, CaptureProgress,
|
||||
InitResponse as InitialData, LibraryNode,
|
||||
InitResponse as InitialData, LibraryNode, QueueSort,
|
||||
};
|
||||
|
||||
pub(crate) use list::{carry_marks, MarkedPane};
|
||||
|
|
@ -106,6 +107,12 @@ pub enum MessageToUi {
|
|||
Init(InitialData),
|
||||
ReplaceLibraryNode(LibraryNode),
|
||||
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
|
||||
|
|
@ -147,6 +154,18 @@ pub enum MessageFromUi {
|
|||
RemoveTracks(Vec<usize>),
|
||||
ReplaceQueue(Vec<String>),
|
||||
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,
|
||||
PrevTrack,
|
||||
RestartTrack,
|
||||
|
|
@ -306,6 +325,10 @@ pub struct App {
|
|||
pub input: Option<InputState>,
|
||||
/// `Some` while a `/` search input is open; modal like the others.
|
||||
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
|
||||
/// status lines at the bottom of the library pane.
|
||||
pub captures: CaptureBoard,
|
||||
|
|
@ -328,6 +351,7 @@ impl App {
|
|||
show_help: false,
|
||||
input: None,
|
||||
search: None,
|
||||
sort_menu: false,
|
||||
captures: CaptureBoard::default(),
|
||||
library,
|
||||
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
|
||||
/// (`search.is_some()`). Typing filters the focused pane live;
|
||||
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
||||
|
|
@ -700,6 +749,22 @@ impl App {
|
|||
self.set_register(dropped);
|
||||
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 => {
|
||||
// Nothing to save from an empty queue; silently ignored
|
||||
// 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 {
|
||||
help::render(f);
|
||||
}
|
||||
|
|
@ -1754,6 +1824,93 @@ mod tests {
|
|||
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]
|
||||
fn queue_download_capture_targets_the_current_queue() {
|
||||
let (mut app, rx) = app();
|
||||
|
|
|
|||
|
|
@ -31,9 +31,16 @@ pub struct Queue {
|
|||
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
|
||||
/// as in the library (architecture/queue-register.md D7).
|
||||
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>,
|
||||
}
|
||||
|
||||
/// How long a dedup result stays in the pane title.
|
||||
const NOTICE_LINGER: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
|
||||
impl Queue {
|
||||
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
||||
Self {
|
||||
|
|
@ -43,10 +50,33 @@ impl Queue {
|
|||
filter: Filter::default(),
|
||||
resolving: false,
|
||||
visual: None,
|
||||
notice: None,
|
||||
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
|
||||
/// active filter — this is what the server-facing ops send.
|
||||
fn selected_position(&self) -> Option<usize> {
|
||||
|
|
@ -290,15 +320,18 @@ impl Queue {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title(if self.visual.is_some() {
|
||||
// Visual (paint-select) mode: movement toggles marks.
|
||||
"Queue — VISUAL".to_string()
|
||||
} else {
|
||||
match (self.filter.query(), register_len) {
|
||||
(Some(query), _) => format!("Queue — /{query}▏"),
|
||||
// One title, several claimants: a mode the user is *in*
|
||||
// (visual, an active search) outranks a result they have
|
||||
// already been shown, which outranks the standing register
|
||||
// count (architecture/queue-order.md D15).
|
||||
.title(match (self.visual.is_some(), self.filter.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, n) => format!("Queue — register: {n}"),
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.highlight_style(Style::default().bg(if focused {
|
||||
|
|
@ -562,6 +595,62 @@ mod tests {
|
|||
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]
|
||||
fn colored_rows_darken_under_the_focused_selection_bar() {
|
||||
// A red (skipped) row under the light focused selection bar was
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod rpc;
|
||||
pub mod stderr;
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
pub mod mpris;
|
||||
|
|
@ -96,7 +97,13 @@ async fn orchestrate(
|
|||
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? {
|
||||
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
|
||||
|
|
@ -108,21 +115,40 @@ async fn orchestrate(
|
|||
if let Some(mpris) = &mpris {
|
||||
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 {
|
||||
if let Err(err) = poll(&mut rpc_client, &rx, &tx, &mpris).await {
|
||||
error!("request to server failed: {err}");
|
||||
match poll(&mut rpc_client, &rx, &tx, &mpris).await {
|
||||
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(
|
||||
rpc_client: &mut RpcClient,
|
||||
rx: &Receiver<MessageFromUi>,
|
||||
tx: &Sender<MessageToUi>,
|
||||
mpris: &Option<mpris::Feed>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
) -> Result<Flow, Box<dyn Error>> {
|
||||
select! {
|
||||
Ok(msg) = &mut rx.recv_async() => {
|
||||
match msg {
|
||||
|
|
@ -218,6 +244,22 @@ async fn poll(
|
|||
MessageFromUi::ClearQueue(exclude_current) => {
|
||||
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) => {
|
||||
// A rejected save (bad name, empty queue) must not tear
|
||||
// down the poll loop; the server logs the cause.
|
||||
|
|
@ -246,7 +288,9 @@ async fn poll(
|
|||
if let Some(mpris) = mpris {
|
||||
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) => {
|
||||
|
|
@ -259,7 +303,7 @@ async fn poll(
|
|||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
|
||||
fn run_ui(
|
||||
|
|
@ -334,6 +378,9 @@ fn run_ui(
|
|||
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);
|
||||
} else if app.input.is_some() {
|
||||
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) {
|
||||
if app.dispatch(action) == DispatchResult::Quit {
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -18,14 +18,12 @@ const CONFIG_FILE: &str = "cbd-tui.toml";
|
|||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
|
||||
/// 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> {
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
|
||||
let log_dir = dirs::state_dir()
|
||||
.or_else(dirs::cache_dir)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("crabidy");
|
||||
let log_dir = cbd_tui::stderr::log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!(
|
||||
"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.
|
||||
None => {
|
||||
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);
|
||||
config::apply_overrides(
|
||||
&mut config,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
||||
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||
SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest,
|
||||
ToggleRepeatRequest, ToggleShuffleRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
|
||||
DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
|
||||
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest,
|
||||
PrevRequest, QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
|
||||
RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
|
||||
StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
||||
};
|
||||
|
||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||
|
|
@ -280,6 +280,33 @@ impl RpcClient {
|
|||
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>> {
|
||||
let save_queue_request = Request::new(SaveQueueRequest { name });
|
||||
self.client.save_queue(save_queue_request).await?;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -601,9 +601,46 @@ 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`
|
||||
/// submit arm.
|
||||
fn submit_name(&self, purpose: NamePurpose, title: String) {
|
||||
|
|
@ -759,18 +796,24 @@ pub fn App() -> impl IntoView {
|
|||
// their own keys), and browser defaults for handled chords are
|
||||
// suppressed so Space does not scroll or Tab move focus.
|
||||
let _handle = window_event_listener(leptos::ev::keydown, move |ev| {
|
||||
let dialog_open = store.dialog.with_untracked(Option::is_some);
|
||||
let help_open = matches!(store.dialog.get_untracked(), Some(Dialog::Help));
|
||||
if dialog_open && !help_open {
|
||||
let dialog = store.dialog.get_untracked();
|
||||
let help_open = matches!(dialog, Some(Dialog::Help));
|
||||
// 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;
|
||||
}
|
||||
if ev.alt_key() || ev.meta_key() {
|
||||
return;
|
||||
}
|
||||
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())
|
||||
{
|
||||
};
|
||||
if let Some(action) = action {
|
||||
ev.prevent_default();
|
||||
store.dispatch(action);
|
||||
}
|
||||
|
|
@ -1093,6 +1136,24 @@ fn QueueView(store: Store) -> impl IntoView {
|
|||
<span class="mode">"VISUAL"</span>
|
||||
</Show>
|
||||
<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
|
||||
class="ghost"
|
||||
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::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]
|
||||
fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView {
|
||||
let value = RwSignal::new(buffer);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
//! Deliberate differences: there is no `q` (quit) in a browser tab, and
|
||||
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
|
||||
|
||||
use crabidy_core::proto::crabidy::QueueSort;
|
||||
|
||||
use crate::state::Focus;
|
||||
|
||||
/// Everything a key can trigger. Mirrors the TUI's `Action` list; the
|
||||
|
|
@ -64,6 +66,86 @@ pub enum Action {
|
|||
QueueClearKeepCurrent,
|
||||
QueueClearAll,
|
||||
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.
|
||||
|
|
@ -271,6 +353,21 @@ pub const HELP: &[HelpEntry] = &[
|
|||
key: "C",
|
||||
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 {
|
||||
scope: "Queue",
|
||||
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::QueueClearAll),
|
||||
"w" => Some(Action::QueueSaveAs),
|
||||
"u" => Some(Action::QueueDedup),
|
||||
"U" => Some(Action::QueueDedupTitles),
|
||||
"S" => Some(Action::QueueSortMenu),
|
||||
_ => 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]
|
||||
fn every_action_reachable_from_help_table() {
|
||||
// The help overlay documents at least every scope we bind.
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest,
|
||||
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest,
|
||||
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||
ToggleShuffleRequest,
|
||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
|
||||
DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
|
||||
GetUpdateStreamResponse, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
|
||||
QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
|
||||
RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
|
||||
ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
||||
};
|
||||
use tonic::{
|
||||
metadata::MetadataValue,
|
||||
|
|
@ -230,6 +230,26 @@ impl Rpc {
|
|||
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> {
|
||||
let request = Request::new(SetCurrentRequest { position });
|
||||
let _ = self.client.set_current(request).await?;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ pub enum Dialog {
|
|||
Login,
|
||||
/// The `?` key binding overlay.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
position: fixed;
|
||||
inset-block-end: 4.5rem;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,16 @@ async fn run_bundle(cli: CbdCli) -> Result<(), Box<dyn Error>> {
|
|||
// Both halves share one file-based subscriber: the terminal belongs
|
||||
// to the TUI, so the server's usual stderr logging would corrupt it.
|
||||
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
|
||||
// standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on
|
||||
// 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> {
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
|
||||
let log_dir = dirs::state_dir()
|
||||
.or_else(dirs::cache_dir)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("crabidy");
|
||||
let log_dir = cbd_tui::stderr::log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!(
|
||||
"could not create log directory {}: {err}",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,14 @@ service CrabidyService {
|
|||
rpc Remove(RemoveRequest) returns (RemoveResponse);
|
||||
rpc Insert(InsertRequest) returns (InsertResponse);
|
||||
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 ToggleShuffle(ToggleShuffleRequest) returns (ToggleShuffleResponse);
|
||||
rpc ToggleRepeat(ToggleRepeatRequest) returns (ToggleRepeatResponse);
|
||||
|
|
@ -182,6 +190,59 @@ message ClearQueueRequest {
|
|||
}
|
||||
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
|
||||
message GetUpdateStreamRequest {}
|
||||
message GetUpdateStreamResponse {
|
||||
|
|
|
|||
|
|
@ -73,9 +73,11 @@ pub fn minimum_role(grpc_path: &str) -> Role {
|
|||
Role::QueueAppender
|
||||
}
|
||||
// Every other queue and playback verb.
|
||||
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
|
||||
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
|
||||
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => Role::QueueOwner,
|
||||
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "DedupQueue" | "SortQueue"
|
||||
| "SetCurrent" | "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop"
|
||||
| "ChangeVolume" | "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => {
|
||||
Role::QueueOwner
|
||||
}
|
||||
// Library writes (CaptureLibraryNode, SaveQueue,
|
||||
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
|
||||
_ => Role::Owner,
|
||||
|
|
@ -345,6 +347,8 @@ mod tests {
|
|||
"Remove",
|
||||
"Insert",
|
||||
"ClearQueue",
|
||||
"DedupQueue",
|
||||
"SortQueue",
|
||||
"SetCurrent",
|
||||
"ToggleShuffle",
|
||||
"ToggleRepeat",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub mod crabidy_store;
|
|||
pub mod orphans;
|
||||
pub mod playback;
|
||||
pub mod provider;
|
||||
pub mod queue_order;
|
||||
pub mod rpc;
|
||||
pub mod settings;
|
||||
#[cfg(feature = "spectrum")]
|
||||
|
|
@ -23,7 +24,7 @@ pub mod spectrum;
|
|||
use audio_player::PlayerMessage;
|
||||
use crabidy_core::proto::crabidy::{
|
||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
||||
Track,
|
||||
QueueSort, Track,
|
||||
};
|
||||
use crabidy_core::ProviderError;
|
||||
use rand::{rng, seq::SliceRandom};
|
||||
|
|
@ -183,31 +184,35 @@ fn spawn_spectrum_task(
|
|||
const FPS: u64 = 20;
|
||||
tokio::spawn(async move {
|
||||
let mut analyzer = spectrum::SpectrumAnalyzer::new(audio_player::SPECTRUM_WINDOW);
|
||||
let mut last_count = tap.frame_count();
|
||||
let mut was_active = false;
|
||||
let mut flow = spectrum::FlowDetector::new(tap.frame_count());
|
||||
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 {
|
||||
interval.tick().await;
|
||||
// Nobody watching: do no work.
|
||||
if update_tx.receiver_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
let count = tap.frame_count();
|
||||
if count != last_count {
|
||||
last_count = count;
|
||||
if !was_active {
|
||||
debug!("spectrum: audio flowing, streaming bars");
|
||||
match flow.observe(tap.frame_count()) {
|
||||
spectrum::Tick::Bars => {
|
||||
let bins = analyzer.analyze(&tap.snapshot());
|
||||
let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins }));
|
||||
}
|
||||
was_active = true;
|
||||
let bins = analyzer.analyze(&tap.snapshot());
|
||||
let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins }));
|
||||
} else if was_active {
|
||||
// Playback just went idle: drop the bars to the floor once.
|
||||
debug!("spectrum: audio idle, bars to zero");
|
||||
was_active = false;
|
||||
let _ = update_tx.send(Update::Spectrum(SpectrumFrame {
|
||||
bins: vec![0.0; spectrum::SPECTRUM_BINS],
|
||||
}));
|
||||
spectrum::Tick::Silence => {
|
||||
// Playback went idle: drop the bars to the floor once.
|
||||
debug!("spectrum: audio idle, bars to zero");
|
||||
let _ = update_tx.send(Update::Spectrum(SpectrumFrame {
|
||||
bins: vec![0.0; spectrum::SPECTRUM_BINS],
|
||||
}));
|
||||
}
|
||||
spectrum::Tick::Nothing => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -664,6 +669,76 @@ impl QueueManager {
|
|||
!exclude_current
|
||||
}
|
||||
|
||||
/// Drops duplicate entries and returns how many were removed
|
||||
/// (architecture/queue-order.md D3–D5).
|
||||
///
|
||||
/// 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 D6–D10).
|
||||
///
|
||||
/// 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
|
||||
/// detected. Loses shuffle history but keeps the queue playable.
|
||||
fn rebuild_play_order(&mut self) {
|
||||
|
|
@ -897,6 +972,175 @@ mod tests {
|
|||
order.sort_unstable();
|
||||
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
|
||||
/// was current when it was sent so the handler can attribute its events to
|
||||
|
|
@ -1049,6 +1293,23 @@ pub enum PlaybackCommand {
|
|||
Clear {
|
||||
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 {
|
||||
position: u32,
|
||||
},
|
||||
|
|
@ -1109,6 +1370,8 @@ impl PlaybackCommand {
|
|||
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
||||
Self::ResolveFinished { .. } => "resolve_finished",
|
||||
Self::Clear { .. } => "clear",
|
||||
Self::DedupQueue { .. } => "dedup_queue",
|
||||
Self::SortQueue { .. } => "sort_queue",
|
||||
Self::SetCurrent { .. } => "set_current",
|
||||
#[cfg(feature = "fs")]
|
||||
Self::SaveQueue { .. } => "save_queue",
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
debug!(position, "jumping to queue position");
|
||||
let track = {
|
||||
|
|
@ -1168,6 +1212,160 @@ mod tests {
|
|||
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
|
||||
/// either `insert_tracks` or `PendingResolve` directly, so none of them
|
||||
/// pinned what the **`Insert` command** does with its position. It is an
|
||||
|
|
|
|||
|
|
@ -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 (D6–D9).
|
||||
|
||||
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 => {
|
||||
// Decorate–sort–undecorate: 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 (D6–D9) ----------------------------------------------
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,17 @@ use crabidy_core::proto::crabidy::{
|
|||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
|
||||
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
|
||||
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest,
|
||||
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse,
|
||||
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
||||
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
|
||||
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
|
||||
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
|
||||
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest, SeekResponse,
|
||||
SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest,
|
||||
ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest,
|
||||
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
||||
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DedupQueueRequest, DedupQueueResponse,
|
||||
DeleteLibraryNodeRequest, DeleteLibraryNodeResponse, GetLibraryNodeRequest,
|
||||
GetLibraryNodeResponse, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InitResponse, InsertRequest, InsertResponse, NextRequest, NextResponse, PrevRequest,
|
||||
PrevResponse, QueueRequest, QueueResponse, QueueSort, RemoveRequest, RemoveResponse,
|
||||
RenameLibraryNodeRequest, RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse,
|
||||
RestartTrackRequest, RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest,
|
||||
SeekResponse, SetCurrentRequest, SetCurrentResponse, SortQueueRequest, SortQueueResponse,
|
||||
StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest,
|
||||
TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest,
|
||||
ToggleShuffleResponse,
|
||||
};
|
||||
use crabidy_core::ProviderError;
|
||||
use std::pin::Pin;
|
||||
|
|
@ -355,6 +356,59 @@ impl CrabidyService for RpcService {
|
|||
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))]
|
||||
async fn set_current(
|
||||
&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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,12 +102,112 @@ fn log_bin_edges(mag_len: usize, bins: usize) -> Vec<usize> {
|
|||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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> {
|
||||
(0..len)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ cbd global volume -- -0.1 # lower the volume
|
|||
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
||||
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
|
||||
<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
|
||||
volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g.
|
||||
`global seek -15`) control playback.
|
||||
|
|
|
|||
|
|
@ -307,6 +307,9 @@ pane).
|
|||
| Queue | `d` | Remove selection (into the register) |
|
||||
| Queue | `c` | Clear queue except current (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` | Capture the queue into /crabidy (audio) |
|
||||
| Queue | `/` | Filter this view |
|
||||
|
|
|
|||
|
|
@ -158,6 +158,70 @@ Two consequences worth knowing:
|
|||
node expands it to tracks at paste time, and a path that no longer
|
||||
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
|
||||
|
||||
`,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing
|
||||
|
|
|
|||
|
|
@ -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`.** Decorate–sort–undecorate 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`.
|
||||
101
plan/summary.md
101
plan/summary.md
|
|
@ -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
|
||||
on `spawn_blocking`, so the shape is fine — but it is a real trap for anyone
|
||||
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 (decorate–sort–undecorate), 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.
|
||||
|
|
|
|||
|
|
@ -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.** Decorate–sort–undecorate,
|
||||
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, D6–D9 |
|
||||
| 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.
|
||||
Loading…
Reference in New Issue