queue: de-duplicate and sort the queue, server-side
Two verbs that rewrite what the queue already holds instead of adding to it: DedupQueue and SortQueue, on the playback loop, for every client. Client-side was the tempting shortcut and the wrong one — a client-computed Remove races the resolve stream, and "sorting" as a Replace of paths re-resolves everything through providers and restarts playback at the head. A duplicate is the same provider item id *scoped to its provider* (the content store keys content the same way, so unscoped ids let two providers' numeric ids collide), or the whole path when a provider reports no id. Deliberately not artist+title: identical metadata is routinely a different recording, and a wrong merge is unrecoverable queue state. Within a group the playing entry survives, else the earliest — "keep the first" stops the music whenever the playing copy is a later one — and the removal itself goes through remove_tracks, so one code path maintains play_order. Sorting permutes `tracks` and then treats the play order by mode: with shuffle off it rebuilds it as the identity with the cursor on the current track (the sort decides what plays next), with shuffle on it remaps it through the permutation so the shuffled sequence and the position in it survive. Five stable strategies (artist→album, album, title, duration, reverse), keys built once per track, unknown sorting last in both directions, and UNSPECIFIED refused as InvalidArgument. DedupQueue answers with a count, alone among the queue verbs: 0 cannot be recovered from the new snapshot and is what a user needs to hear. It crosses as a number — the wording is each client's. TUI: `u` and a modal `S` sort menu whose unclaimed keys are swallowed, plus the count in the queue pane title. Web: the same keys, clickable menu rows, toolbar buttons, count in a toast. CLI: `queue dedup` and `queue sort <key> [--desc]` with the strategy as a ValueEnum. Full dev-flow: architecture/queue-order.md, quality/queue-order.md, plan/queue-order.md, plan/summary.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1228c7cb70
commit
001abcc35c
|
|
@ -0,0 +1,338 @@
|
||||||
|
# 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, falling back to 1** (D3). 3 is rejected: 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. Dropping
|
||||||
|
one would be a silent, unrecoverable edit of the user's queue, and the false
|
||||||
|
positives land exactly on the collections (greatest-hits, live albums) where
|
||||||
|
a user is most deliberate. A fuzzy dedup belongs behind a client-side preview
|
||||||
|
where the user confirms each pair; that is Deferred.
|
||||||
|
|
||||||
|
## 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 — 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.
|
||||||
|
- **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, `S` opens a sort menu.** `u` is "unique" and is
|
||||||
|
free in the queue scope. 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
|
||||||
|
|
||||||
|
- **Fuzzy dedup** (artist/title matching) behind a client-side confirmation
|
||||||
|
view — the only safe home for it (see the options above).
|
||||||
|
- **`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::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
|
||||||
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
|
DeleteLibraryNodeRequest, GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode,
|
||||||
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest,
|
NextRequest, PrevRequest, Queue, QueueSort, RemoveRequest, RenameLibraryNodeRequest,
|
||||||
SaveQueueRequest, SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest,
|
ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest,
|
||||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, Track,
|
SortQueueRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||||
|
ToggleShuffleRequest, Track,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd};
|
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd, SortKey};
|
||||||
|
|
||||||
/// The reserved library path the live queue mirrors into; `queue capture`
|
/// The reserved library path the live queue mirrors into; `queue capture`
|
||||||
/// captures it (architecture/crabidy-store.md).
|
/// captures it (architecture/crabidy-store.md).
|
||||||
|
|
@ -167,6 +168,29 @@ async fn run_library(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The wire strategy a CLI `sort <key>` names (architecture/queue-order.md D17).
|
||||||
|
fn wire_sort(key: SortKey) -> QueueSort {
|
||||||
|
match key {
|
||||||
|
SortKey::Artist => QueueSort::Artist,
|
||||||
|
SortKey::Album => QueueSort::Album,
|
||||||
|
SortKey::Title => QueueSort::Title,
|
||||||
|
SortKey::Duration => QueueSort::Duration,
|
||||||
|
SortKey::Reverse => QueueSort::Reverse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI spelling of a strategy, so the confirmation line names it the way
|
||||||
|
/// the user typed it.
|
||||||
|
fn sort_label(key: SortKey) -> &'static str {
|
||||||
|
match key {
|
||||||
|
SortKey::Artist => "artist",
|
||||||
|
SortKey::Album => "album",
|
||||||
|
SortKey::Title => "title",
|
||||||
|
SortKey::Duration => "duration",
|
||||||
|
SortKey::Reverse => "reverse",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match cmd {
|
match cmd {
|
||||||
QueueCmd::Show => {
|
QueueCmd::Show => {
|
||||||
|
|
@ -217,6 +241,29 @@ async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std
|
||||||
.map_err(rpc_error)?;
|
.map_err(rpc_error)?;
|
||||||
println!("cleared the queue");
|
println!("cleared the queue");
|
||||||
}
|
}
|
||||||
|
QueueCmd::Dedup => {
|
||||||
|
let response = client
|
||||||
|
.dedup_queue(DedupQueueRequest {})
|
||||||
|
.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).
|
||||||
|
println!("removed {} duplicate(s)", response.into_inner().removed);
|
||||||
|
}
|
||||||
|
QueueCmd::Sort { key, desc } => {
|
||||||
|
client
|
||||||
|
.sort_queue(SortQueueRequest {
|
||||||
|
sort: wire_sort(key) as i32,
|
||||||
|
descending: desc,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
match key {
|
||||||
|
SortKey::Reverse => println!("reversed the queue"),
|
||||||
|
_ if desc => println!("sorted the queue by {} (descending)", sort_label(key)),
|
||||||
|
_ => println!("sorted the queue by {}", sort_label(key)),
|
||||||
|
}
|
||||||
|
}
|
||||||
QueueCmd::SetCurrent { position } => {
|
QueueCmd::SetCurrent { position } => {
|
||||||
client
|
client
|
||||||
.set_current(SetCurrentRequest { position })
|
.set_current(SetCurrentRequest { position })
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,23 @@ pub enum LibraryCmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue operations against a running server.
|
/// Queue operations against a running server.
|
||||||
|
/// How `queue sort` orders the queue — the CLI spelling of the wire's
|
||||||
|
/// `QueueSort` (architecture/queue-order.md D17). A `ValueEnum` so the shell
|
||||||
|
/// completes it and a typo is a parse error instead of a round trip.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum SortKey {
|
||||||
|
/// Artist, then album; queue order within an album.
|
||||||
|
Artist,
|
||||||
|
/// Album title.
|
||||||
|
Album,
|
||||||
|
/// Track title.
|
||||||
|
Title,
|
||||||
|
/// Duration; tracks with no known length sort last.
|
||||||
|
Duration,
|
||||||
|
/// Reverse the order the queue is in now.
|
||||||
|
Reverse,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum QueueCmd {
|
pub enum QueueCmd {
|
||||||
/// Print the current queue.
|
/// Print the current queue.
|
||||||
|
|
@ -88,6 +105,18 @@ pub enum QueueCmd {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
keep_current: bool,
|
keep_current: bool,
|
||||||
},
|
},
|
||||||
|
/// Drop duplicate entries, keeping one copy of each track. Prints how
|
||||||
|
/// many were removed; never removes the playing track.
|
||||||
|
Dedup,
|
||||||
|
/// Reorder the queue by one strategy (architecture/queue-order.md).
|
||||||
|
Sort {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
key: SortKey,
|
||||||
|
/// Largest/last first. Ignored by `reverse`; blanks and unknown
|
||||||
|
/// durations sort last either way.
|
||||||
|
#[arg(long)]
|
||||||
|
desc: bool,
|
||||||
|
},
|
||||||
/// Jump to a queue position.
|
/// Jump to a queue position.
|
||||||
SetCurrent { position: u32 },
|
SetCurrent { position: u32 },
|
||||||
/// Link-save the current queue as `/crabidy/<name>`.
|
/// Link-save the current queue as `/crabidy/<name>`.
|
||||||
|
|
@ -401,6 +430,47 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_dedup_takes_no_arguments() {
|
||||||
|
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "dedup"]).expect("parse");
|
||||||
|
assert!(matches!(
|
||||||
|
cli.command,
|
||||||
|
Some(TuiCommand::Queue(QueueCmd::Dedup))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strategy is a value enum, so it is spelled lowercase, completes in
|
||||||
|
/// a shell, and a typo fails at parse time rather than as a server
|
||||||
|
/// `InvalidArgument` (architecture/queue-order.md D17).
|
||||||
|
#[test]
|
||||||
|
fn queue_sort_parses_a_strategy_and_an_optional_direction() {
|
||||||
|
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "artist"]).expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(TuiCommand::Queue(QueueCmd::Sort { key, desc })) => {
|
||||||
|
assert_eq!(key, SortKey::Artist);
|
||||||
|
assert!(!desc, "ascending unless asked");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "duration", "--desc"])
|
||||||
|
.expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(TuiCommand::Queue(QueueCmd::Sort { key, desc })) => {
|
||||||
|
assert_eq!(key, SortKey::Duration);
|
||||||
|
assert!(desc);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
TuiCli::try_parse_from(["cbd-tui", "queue", "sort", "artiste"]).is_err(),
|
||||||
|
"a misspelled strategy must not reach the server"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
TuiCli::try_parse_from(["cbd-tui", "queue", "sort"]).is_err(),
|
||||||
|
"the strategy is required"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn global_volume_parses_a_signed_delta() {
|
fn global_volume_parses_a_signed_delta() {
|
||||||
let cli =
|
let cli =
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,15 @@ pub enum Action {
|
||||||
QueueRemoveTrack,
|
QueueRemoveTrack,
|
||||||
QueueClearKeepCurrent,
|
QueueClearKeepCurrent,
|
||||||
QueueClearAll,
|
QueueClearAll,
|
||||||
|
/// Drop duplicate entries from the queue, server-side. The playing track
|
||||||
|
/// is never the one removed, so it cannot interrupt playback; the number
|
||||||
|
/// removed appears in the pane title for a few seconds
|
||||||
|
/// (architecture/queue-order.md).
|
||||||
|
QueueDedup,
|
||||||
|
/// Open the sort menu: one keypress there reorders the whole queue
|
||||||
|
/// (architecture/queue-order.md D14). Modal — while it is open, this
|
||||||
|
/// table is unreachable.
|
||||||
|
QueueSortMenu,
|
||||||
/// Open the input overlay asking for a name to save the queue under
|
/// Open the input overlay asking for a name to save the queue under
|
||||||
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
|
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
|
||||||
QueueSaveAs,
|
QueueSaveAs,
|
||||||
|
|
@ -552,6 +561,20 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::QueueClearAll,
|
action: Action::QueueClearAll,
|
||||||
description: "Clear entire queue",
|
description: "Clear entire queue",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('u'),
|
||||||
|
action: Action::QueueDedup,
|
||||||
|
description: "Unique: drop duplicate tracks from the queue",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('S'),
|
||||||
|
action: Action::QueueSortMenu,
|
||||||
|
description: "Sort the queue (opens a menu of strategies)",
|
||||||
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
|
|
@ -1056,6 +1079,45 @@ 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)
|
||||||
|
);
|
||||||
|
for code in [KeyCode::Char('u'), KeyCode::Char('S')] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(code, KeyModifiers::NONE)),
|
||||||
|
None,
|
||||||
|
"{code:?} belongs to the queue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('u'), KeyModifiers::CONTROL)
|
||||||
|
),
|
||||||
|
Some(Action::QueueJumpUp),
|
||||||
|
"Ctrl-u still jumps"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_labels_are_human_readable() {
|
fn key_labels_are_human_readable() {
|
||||||
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q");
|
assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q");
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mod list;
|
||||||
mod now_playing;
|
mod now_playing;
|
||||||
mod queue;
|
mod queue;
|
||||||
mod register;
|
mod register;
|
||||||
|
mod sort;
|
||||||
|
|
||||||
use flume::Sender;
|
use flume::Sender;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
|
@ -16,7 +17,7 @@ use ratatui::{
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
get_update_stream_response::Update as StreamUpdate, CaptureProgress,
|
get_update_stream_response::Update as StreamUpdate, CaptureProgress,
|
||||||
InitResponse as InitialData, LibraryNode,
|
InitResponse as InitialData, LibraryNode, QueueSort,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) use list::{carry_marks, MarkedPane};
|
pub(crate) use list::{carry_marks, MarkedPane};
|
||||||
|
|
@ -106,6 +107,12 @@ pub enum MessageToUi {
|
||||||
Init(InitialData),
|
Init(InitialData),
|
||||||
ReplaceLibraryNode(LibraryNode),
|
ReplaceLibraryNode(LibraryNode),
|
||||||
Update(StreamUpdate),
|
Update(StreamUpdate),
|
||||||
|
/// A `DedupQueue` finished and removed this many entries — the count the
|
||||||
|
/// queue pane shows for a few seconds (architecture/queue-order.md D15).
|
||||||
|
/// Typed rather than a preformatted string: the wording belongs to the UI.
|
||||||
|
QueueDeduped {
|
||||||
|
removed: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Rename this
|
// FIXME: Rename this
|
||||||
|
|
@ -147,6 +154,15 @@ pub enum MessageFromUi {
|
||||||
RemoveTracks(Vec<usize>),
|
RemoveTracks(Vec<usize>),
|
||||||
ReplaceQueue(Vec<String>),
|
ReplaceQueue(Vec<String>),
|
||||||
ClearQueue(bool),
|
ClearQueue(bool),
|
||||||
|
/// Drop duplicate queue entries; the reply comes back as
|
||||||
|
/// [`MessageToUi::QueueDeduped`] (architecture/queue-order.md).
|
||||||
|
DedupQueue,
|
||||||
|
/// Reorder the queue by one strategy. Fire-and-forget: the new order
|
||||||
|
/// arrives on the update stream like every other queue change.
|
||||||
|
SortQueue {
|
||||||
|
sort: QueueSort,
|
||||||
|
descending: bool,
|
||||||
|
},
|
||||||
NextTrack,
|
NextTrack,
|
||||||
PrevTrack,
|
PrevTrack,
|
||||||
RestartTrack,
|
RestartTrack,
|
||||||
|
|
@ -306,6 +322,10 @@ pub struct App {
|
||||||
pub input: Option<InputState>,
|
pub input: Option<InputState>,
|
||||||
/// `Some` while a `/` search input is open; modal like the others.
|
/// `Some` while a `/` search input is open; modal like the others.
|
||||||
pub search: Option<SearchState>,
|
pub search: Option<SearchState>,
|
||||||
|
/// True while the queue sort menu is open. Modal like the others: keys go
|
||||||
|
/// to [`Self::handle_sort_key`] and the bindings table is unreachable
|
||||||
|
/// (architecture/queue-order.md D14).
|
||||||
|
pub sort_menu: bool,
|
||||||
/// Progress of running (and recently finished) captures, rendered as
|
/// Progress of running (and recently finished) captures, rendered as
|
||||||
/// status lines at the bottom of the library pane.
|
/// status lines at the bottom of the library pane.
|
||||||
pub captures: CaptureBoard,
|
pub captures: CaptureBoard,
|
||||||
|
|
@ -328,6 +348,7 @@ impl App {
|
||||||
show_help: false,
|
show_help: false,
|
||||||
input: None,
|
input: None,
|
||||||
search: None,
|
search: None,
|
||||||
|
sort_menu: false,
|
||||||
captures: CaptureBoard::default(),
|
captures: CaptureBoard::default(),
|
||||||
library,
|
library,
|
||||||
now_playing,
|
now_playing,
|
||||||
|
|
@ -337,6 +358,31 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles one key while the sort menu is open (`sort_menu`).
|
||||||
|
///
|
||||||
|
/// A key the menu claims sends the sort and closes; `Esc`, `q` and `S`
|
||||||
|
/// close without sorting; anything else is ignored and the menu stays up,
|
||||||
|
/// so a mistyped key does not silently drop back into the bindings table
|
||||||
|
/// where it would *do* something (architecture/queue-order.md D14).
|
||||||
|
pub fn handle_sort_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => self.sort_menu = false,
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if let Some((sort, descending)) = sort::choose(c) {
|
||||||
|
let _ = self.tx.send(MessageFromUi::SortQueue { sort, descending });
|
||||||
|
self.sort_menu = false;
|
||||||
|
} else if matches!(c, 'q' | 'S') {
|
||||||
|
// The keys that got here (and the one that opened it).
|
||||||
|
self.sort_menu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every other key is swallowed: the menu stays up rather than
|
||||||
|
// letting a stray keypress reach the pane bindings.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles one key while a `/` search input is open
|
/// Handles one key while a `/` search input is open
|
||||||
/// (`search.is_some()`). Typing filters the focused pane live;
|
/// (`search.is_some()`). Typing filters the focused pane live;
|
||||||
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
||||||
|
|
@ -700,6 +746,21 @@ impl App {
|
||||||
self.set_register(dropped);
|
self.set_register(dropped);
|
||||||
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
|
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
|
||||||
}
|
}
|
||||||
|
Action::QueueDedup => {
|
||||||
|
// 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 _ = self.tx.send(MessageFromUi::DedupQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::QueueSortMenu => {
|
||||||
|
// Opening on an empty queue would offer five ways to sort
|
||||||
|
// nothing.
|
||||||
|
if !self.queue.is_empty() {
|
||||||
|
self.sort_menu = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::QueueSaveAs => {
|
Action::QueueSaveAs => {
|
||||||
// Nothing to save from an empty queue; silently ignored
|
// Nothing to save from an empty queue; silently ignored
|
||||||
// like the other capability-gated openers.
|
// like the other capability-gated openers.
|
||||||
|
|
@ -821,7 +882,12 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The help modal renders last so it overlays every pane.
|
// The sort menu and the help modal render last so they overlay every
|
||||||
|
// pane. Both cannot be open at once (each is modal), so the order
|
||||||
|
// between them is arbitrary.
|
||||||
|
if self.sort_menu {
|
||||||
|
sort::render(f);
|
||||||
|
}
|
||||||
if self.show_help {
|
if self.show_help {
|
||||||
help::render(f);
|
help::render(f);
|
||||||
}
|
}
|
||||||
|
|
@ -1754,6 +1820,83 @@ mod tests {
|
||||||
assert_eq!(input.buffer, "");
|
assert_eq!(input.buffer, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- queue order (architecture/queue-order.md) ---------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_asks_the_server_only_with_a_non_empty_queue() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
assert_eq!(app.dispatch(Action::QueueDedup), DispatchResult::Continue);
|
||||||
|
assert!(rx.try_recv().is_err(), "nothing to dedup in an empty queue");
|
||||||
|
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
let _ = app.dispatch(Action::QueueDedup);
|
||||||
|
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::DedupQueue)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sort_menu_opens_only_with_a_non_empty_queue() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
let _ = app.dispatch(Action::QueueSortMenu);
|
||||||
|
assert!(!app.sort_menu, "no menu for an empty queue");
|
||||||
|
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
let _ = app.dispatch(Action::QueueSortMenu);
|
||||||
|
assert!(app.sort_menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sort_menu_key_sends_the_strategy_and_closes_the_menu() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
let _ = app.dispatch(Action::QueueSortMenu);
|
||||||
|
|
||||||
|
app.handle_sort_key(key(crossterm::event::KeyCode::Char('A')));
|
||||||
|
assert!(!app.sort_menu, "picking a strategy closes the menu");
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(MessageFromUi::SortQueue { sort, descending }) => {
|
||||||
|
assert_eq!(sort, QueueSort::Artist);
|
||||||
|
assert!(descending, "the capital sorts descending");
|
||||||
|
}
|
||||||
|
other => panic!("expected a sort message, got {:?}", other.is_ok()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_closes_the_sort_menu_without_sorting() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
for closer in [
|
||||||
|
crossterm::event::KeyCode::Esc,
|
||||||
|
crossterm::event::KeyCode::Char('q'),
|
||||||
|
crossterm::event::KeyCode::Char('S'),
|
||||||
|
] {
|
||||||
|
let _ = app.dispatch(Action::QueueSortMenu);
|
||||||
|
app.handle_sort_key(key(closer));
|
||||||
|
assert!(!app.sort_menu, "{closer:?} must close the menu");
|
||||||
|
assert!(rx.try_recv().is_err(), "{closer:?} must not sort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key the menu does not offer keeps it open and does nothing. Falling
|
||||||
|
/// back to the bindings table would let a stray `c` clear the queue while
|
||||||
|
/// the user thinks they are choosing a sort
|
||||||
|
/// (architecture/queue-order.md D14).
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_key_leaves_the_sort_menu_open() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
let _ = app.dispatch(Action::QueueSortMenu);
|
||||||
|
for stray in [
|
||||||
|
crossterm::event::KeyCode::Char('c'),
|
||||||
|
crossterm::event::KeyCode::Char('x'),
|
||||||
|
crossterm::event::KeyCode::Enter,
|
||||||
|
] {
|
||||||
|
app.handle_sort_key(key(stray));
|
||||||
|
assert!(app.sort_menu, "{stray:?} must not close the menu");
|
||||||
|
assert!(rx.try_recv().is_err(), "{stray:?} must not do anything");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_download_capture_targets_the_current_queue() {
|
fn queue_download_capture_targets_the_current_queue() {
|
||||||
let (mut app, rx) = app();
|
let (mut app, rx) = app();
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,16 @@ pub struct Queue {
|
||||||
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
|
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
|
||||||
/// as in the library (architecture/queue-register.md D7).
|
/// as in the library (architecture/queue-register.md D7).
|
||||||
visual: Option<usize>,
|
visual: Option<usize>,
|
||||||
|
/// The result of the last dedup and when it arrived: shown in the pane
|
||||||
|
/// title for [`NOTICE_LINGER`], then gone
|
||||||
|
/// (architecture/queue-order.md D15).
|
||||||
|
notice: Option<(String, std::time::Instant)>,
|
||||||
tx: Sender<MessageFromUi>,
|
tx: Sender<MessageFromUi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a dedup result stays in the pane title.
|
||||||
|
const NOTICE_LINGER: std::time::Duration = std::time::Duration::from_secs(4);
|
||||||
|
|
||||||
impl Queue {
|
impl Queue {
|
||||||
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -43,10 +50,33 @@ impl Queue {
|
||||||
filter: Filter::default(),
|
filter: Filter::default(),
|
||||||
resolving: false,
|
resolving: false,
|
||||||
visual: None,
|
visual: None,
|
||||||
|
notice: None,
|
||||||
tx,
|
tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows how many entries a dedup removed, in the pane title, for a few
|
||||||
|
/// seconds (architecture/queue-order.md D15). `0` is worth saying: it
|
||||||
|
/// means the queue held no duplicates.
|
||||||
|
pub fn show_dedup_result(&mut self, removed: u32) {
|
||||||
|
let text = match removed {
|
||||||
|
0 => "no duplicates".to_string(),
|
||||||
|
1 => "removed 1 duplicate".to_string(),
|
||||||
|
n => format!("removed {n} duplicates"),
|
||||||
|
};
|
||||||
|
self.notice = Some((text, std::time::Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title note to render right now, or `None` once it has expired.
|
||||||
|
/// Separate from [`Self::show_dedup_result`] so the expiry is checked at
|
||||||
|
/// render time rather than on a timer.
|
||||||
|
fn notice(&self) -> Option<&str> {
|
||||||
|
self.notice
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(_, at)| at.elapsed() < NOTICE_LINGER)
|
||||||
|
.map(|(text, _)| text.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
/// The real queue position under the cursor, mapped through the
|
/// The real queue position under the cursor, mapped through the
|
||||||
/// active filter — this is what the server-facing ops send.
|
/// active filter — this is what the server-facing ops send.
|
||||||
fn selected_position(&self) -> Option<usize> {
|
fn selected_position(&self) -> Option<usize> {
|
||||||
|
|
@ -290,15 +320,18 @@ impl Queue {
|
||||||
} else {
|
} else {
|
||||||
COLOR_PRIMARY_DARK
|
COLOR_PRIMARY_DARK
|
||||||
}))
|
}))
|
||||||
.title(if self.visual.is_some() {
|
// One title, several claimants: a mode the user is *in*
|
||||||
// Visual (paint-select) mode: movement toggles marks.
|
// (visual, an active search) outranks a result they have
|
||||||
"Queue — VISUAL".to_string()
|
// already been shown, which outranks the standing register
|
||||||
} else {
|
// count (architecture/queue-order.md D15).
|
||||||
match (self.filter.query(), register_len) {
|
.title(match (self.visual.is_some(), self.filter.query()) {
|
||||||
(Some(query), _) => format!("Queue — /{query}▏"),
|
(true, _) => "Queue — VISUAL".to_string(),
|
||||||
|
(false, Some(query)) => format!("Queue — /{query}▏"),
|
||||||
|
(false, None) => match (self.notice(), register_len) {
|
||||||
|
(Some(notice), _) => format!("Queue — {notice}"),
|
||||||
(None, 0) => "Queue".to_string(),
|
(None, 0) => "Queue".to_string(),
|
||||||
(None, n) => format!("Queue — register: {n}"),
|
(None, n) => format!("Queue — register: {n}"),
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.highlight_style(Style::default().bg(if focused {
|
.highlight_style(Style::default().bg(if focused {
|
||||||
|
|
@ -562,6 +595,62 @@ mod tests {
|
||||||
assert_eq!(queue.filter_query(), Some("beta"));
|
assert_eq!(queue.filter_query(), Some("beta"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dedup result is shown in the pane title, and `0` is shown too — it
|
||||||
|
/// is the answer "no duplicates" (architecture/queue-order.md D15).
|
||||||
|
#[test]
|
||||||
|
fn the_dedup_result_appears_in_the_title() {
|
||||||
|
let (tx, _rx) = flume::unbounded();
|
||||||
|
let mut queue = Queue::new(tx);
|
||||||
|
queue.update_queue(queue_data(&["one", "two"], false));
|
||||||
|
queue.show_dedup_result(7);
|
||||||
|
let title = rendered_rows(&mut queue).remove(0);
|
||||||
|
assert!(title.contains('7'), "title: {title:?}");
|
||||||
|
// Zero is an answer, not a non-event: it has to say so in words a
|
||||||
|
// user can act on, not read as a dropped keypress.
|
||||||
|
queue.show_dedup_result(0);
|
||||||
|
let title = rendered_rows(&mut queue).remove(0);
|
||||||
|
assert!(
|
||||||
|
title.contains("no duplicates"),
|
||||||
|
"a zero count is still an answer: {title:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_dedup_result_expires() {
|
||||||
|
let (tx, _rx) = flume::unbounded();
|
||||||
|
let mut queue = Queue::new(tx);
|
||||||
|
queue.update_queue(queue_data(&["one"], false));
|
||||||
|
queue.show_dedup_result(3);
|
||||||
|
assert!(queue.notice().is_some());
|
||||||
|
// Backdate the stamp past the linger; the expiry is checked at render
|
||||||
|
// time, so nothing else has to run.
|
||||||
|
queue.notice = queue.notice.take().map(|(text, at)| {
|
||||||
|
(
|
||||||
|
text,
|
||||||
|
at - NOTICE_LINGER - std::time::Duration::from_millis(1),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(queue.notice().is_none());
|
||||||
|
let title = rendered_rows(&mut queue).remove(0);
|
||||||
|
assert!(
|
||||||
|
!title.contains('3'),
|
||||||
|
"expired notice still shown: {title:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title has one slot and several claimants; a mode the user is *in*
|
||||||
|
/// outranks a result they have already seen.
|
||||||
|
#[test]
|
||||||
|
fn an_active_search_outranks_the_dedup_result() {
|
||||||
|
let (tx, _rx) = flume::unbounded();
|
||||||
|
let mut queue = Queue::new(tx);
|
||||||
|
queue.update_queue(queue_data(&["alpha", "beta"], false));
|
||||||
|
queue.set_filter(Some("bet".to_string()));
|
||||||
|
queue.show_dedup_result(2);
|
||||||
|
let title = rendered_rows(&mut queue).remove(0);
|
||||||
|
assert!(title.contains("/bet"), "title: {title:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colored_rows_darken_under_the_focused_selection_bar() {
|
fn colored_rows_darken_under_the_focused_selection_bar() {
|
||||||
// A red (skipped) row under the light focused selection bar was
|
// A red (skipped) row under the light focused selection bar was
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -218,6 +218,22 @@ async fn poll(
|
||||||
MessageFromUi::ClearQueue(exclude_current) => {
|
MessageFromUi::ClearQueue(exclude_current) => {
|
||||||
rpc_client.clear_queue(exclude_current).await?
|
rpc_client.clear_queue(exclude_current).await?
|
||||||
}
|
}
|
||||||
|
MessageFromUi::DedupQueue => {
|
||||||
|
// 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().await {
|
||||||
|
Ok(removed) => {
|
||||||
|
let _ = tx.send(MessageToUi::QueueDeduped { removed });
|
||||||
|
}
|
||||||
|
Err(err) => error!("failed to dedup the queue: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageFromUi::SortQueue { sort, descending } => {
|
||||||
|
if let Err(err) = rpc_client.sort_queue(sort, descending).await {
|
||||||
|
error!(?sort, descending, "failed to sort the queue: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
MessageFromUi::SaveQueue(name) => {
|
MessageFromUi::SaveQueue(name) => {
|
||||||
// A rejected save (bad name, empty queue) must not tear
|
// A rejected save (bad name, empty queue) must not tear
|
||||||
// down the poll loop; the server logs the cause.
|
// down the poll loop; the server logs the cause.
|
||||||
|
|
@ -334,6 +350,9 @@ fn run_ui(
|
||||||
app.now_playing.update_spectrum(frame.bins);
|
app.now_playing.update_spectrum(frame.bins);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
MessageToUi::QueueDeduped { removed } => {
|
||||||
|
app.queue.show_dedup_result(removed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,6 +375,8 @@ fn run_ui(
|
||||||
app.handle_search_key(key);
|
app.handle_search_key(key);
|
||||||
} else if app.input.is_some() {
|
} else if app.input.is_some() {
|
||||||
app.handle_input_key(key);
|
app.handle_input_key(key);
|
||||||
|
} else if app.sort_menu {
|
||||||
|
app.handle_sort_key(key);
|
||||||
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
||||||
if app.dispatch(action) == DispatchResult::Quit {
|
if app.dispatch(action) == DispatchResult::Quit {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
|
||||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
|
||||||
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest,
|
||||||
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
PrevRequest, QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
|
||||||
SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest,
|
RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
|
||||||
ToggleRepeatRequest, ToggleShuffleRequest,
|
StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||||
|
|
@ -280,6 +280,33 @@ impl RpcClient {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drops duplicate queue entries and returns how many went
|
||||||
|
/// (architecture/queue-order.md D2) — the one queue verb with an answer
|
||||||
|
/// worth showing, because `0` means "no duplicates", not "nothing
|
||||||
|
/// happened".
|
||||||
|
pub async fn dedup_queue(&mut self) -> Result<u32, Box<dyn Error>> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.dedup_queue(Request::new(DedupQueueRequest {}))
|
||||||
|
.await?;
|
||||||
|
Ok(response.into_inner().removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorders the queue server-side; the new order arrives on the update
|
||||||
|
/// stream like any other queue change.
|
||||||
|
pub async fn sort_queue(
|
||||||
|
&mut self,
|
||||||
|
sort: QueueSort,
|
||||||
|
descending: bool,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let request = Request::new(SortQueueRequest {
|
||||||
|
sort: sort as i32,
|
||||||
|
descending,
|
||||||
|
});
|
||||||
|
self.client.sort_queue(request).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
|
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
|
||||||
let save_queue_request = Request::new(SaveQueueRequest { name });
|
let save_queue_request = Request::new(SaveQueueRequest { name });
|
||||||
self.client.save_queue(save_queue_request).await?;
|
self.client.save_queue(save_queue_request).await?;
|
||||||
|
|
|
||||||
|
|
@ -601,7 +601,43 @@ impl Store {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::QueueDedup => {
|
||||||
|
if self.queue.with_untracked(|q| !q.is_empty()) {
|
||||||
|
let this = *self;
|
||||||
|
let Some(mut rpc) = self.rpc() else { return };
|
||||||
|
spawn_local(async move {
|
||||||
|
match rpc.dedup_queue().await {
|
||||||
|
// 0 is an answer, not a non-event
|
||||||
|
// (architecture/queue-order.md D2).
|
||||||
|
Ok(removed) => this.notify(format!("removed {removed} duplicate(s)")),
|
||||||
|
Err(status) => this.fail(status),
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::QueueSortMenu => {
|
||||||
|
// Five ways to sort nothing is not a menu.
|
||||||
|
if self.queue.with_untracked(|q| !q.is_empty()) {
|
||||||
|
self.dialog.set(Some(Dialog::Sort));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::CloseSortMenu => self.dialog.set(None),
|
||||||
|
Action::QueueSort { sort, descending } => {
|
||||||
|
self.dialog.set(None);
|
||||||
|
self.call(async move |mut rpc: Rpc| rpc.sort_queue(sort, descending).await)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a plain notice in the same toast errors use — a result the user
|
||||||
|
/// asked for, not a failure (architecture/queue-order.md D16).
|
||||||
|
fn notify(&self, message: String) {
|
||||||
|
let toast = self.toast;
|
||||||
|
toast.set(Some(message));
|
||||||
|
spawn_local(async move {
|
||||||
|
gloo_timers::future::TimeoutFuture::new(TOAST_MS).await;
|
||||||
|
toast.set(None);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submits the name dialog (Enter) — the TUI's `handle_input_key`
|
/// Submits the name dialog (Enter) — the TUI's `handle_input_key`
|
||||||
|
|
@ -759,18 +795,24 @@ pub fn App() -> impl IntoView {
|
||||||
// their own keys), and browser defaults for handled chords are
|
// their own keys), and browser defaults for handled chords are
|
||||||
// suppressed so Space does not scroll or Tab move focus.
|
// suppressed so Space does not scroll or Tab move focus.
|
||||||
let _handle = window_event_listener(leptos::ev::keydown, move |ev| {
|
let _handle = window_event_listener(leptos::ev::keydown, move |ev| {
|
||||||
let dialog_open = store.dialog.with_untracked(Option::is_some);
|
let dialog = store.dialog.get_untracked();
|
||||||
let help_open = matches!(store.dialog.get_untracked(), Some(Dialog::Help));
|
let help_open = matches!(dialog, Some(Dialog::Help));
|
||||||
if dialog_open && !help_open {
|
// Help and the sort menu are the two dialogs that *answer* keys; the
|
||||||
|
// rest (text input, login) own their fields.
|
||||||
|
let sort_open = matches!(dialog, Some(Dialog::Sort));
|
||||||
|
if dialog.is_some() && !help_open && !sort_open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ev.alt_key() || ev.meta_key() {
|
if ev.alt_key() || ev.meta_key() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let key = ev.key();
|
let key = ev.key();
|
||||||
if let Some(action) =
|
let action = if sort_open {
|
||||||
|
keymap::sort_menu_key(&key)
|
||||||
|
} else {
|
||||||
keymap::lookup(store.focus.get_untracked(), help_open, &key, ev.ctrl_key())
|
keymap::lookup(store.focus.get_untracked(), help_open, &key, ev.ctrl_key())
|
||||||
{
|
};
|
||||||
|
if let Some(action) = action {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
store.dispatch(action);
|
store.dispatch(action);
|
||||||
}
|
}
|
||||||
|
|
@ -1093,6 +1135,24 @@ fn QueueView(store: Store) -> impl IntoView {
|
||||||
<span class="mode">"VISUAL"</span>
|
<span class="mode">"VISUAL"</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
// The two order operations. Buttons as well as keys: `u` and
|
||||||
|
// `S` are invisible to a mouse (architecture/queue-order.md
|
||||||
|
// D16), and "sort" opens the same menu the key does, so the
|
||||||
|
// strategy list exists exactly once.
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
title="sort the queue (S)"
|
||||||
|
on:click=move |_| store.dispatch(Action::QueueSortMenu)
|
||||||
|
>
|
||||||
|
"sort"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
title="drop duplicate tracks (u)"
|
||||||
|
on:click=move |_| store.dispatch(Action::QueueDedup)
|
||||||
|
>
|
||||||
|
"dedup"
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ghost"
|
class="ghost"
|
||||||
title="paste the register after selected (p)"
|
title="paste the register after selected (p)"
|
||||||
|
|
@ -1320,10 +1380,52 @@ fn Dialogs(store: Store) -> impl IntoView {
|
||||||
}
|
}
|
||||||
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
|
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
|
||||||
Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(),
|
Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(),
|
||||||
|
Dialog::Sort => view! { <SortMenu store=store /> }.into_any(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The queue sort menu (`S`): the same five strategies as the TUI, each a
|
||||||
|
/// keypress or a click (architecture/queue-order.md D16). Keys are handled by
|
||||||
|
/// the global listener through `keymap::sort_menu_key`, so a row's click and
|
||||||
|
/// its letter dispatch the very same action.
|
||||||
|
#[component]
|
||||||
|
fn SortMenu(store: Store) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="overlay" on:click=move |_| store.dialog.set(None)>
|
||||||
|
<div class="dialog" on:click=|ev| ev.stop_propagation()>
|
||||||
|
<h2>"Sort queue"</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{keymap::SORT_CHOICES
|
||||||
|
.iter()
|
||||||
|
.map(|choice| {
|
||||||
|
let sort = choice.sort;
|
||||||
|
view! {
|
||||||
|
<tr
|
||||||
|
class="sort-choice"
|
||||||
|
on:click=move |_| {
|
||||||
|
store
|
||||||
|
.dispatch(Action::QueueSort {
|
||||||
|
sort,
|
||||||
|
descending: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td class="key">{choice.key.to_string()}</td>
|
||||||
|
<td>{choice.description}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="hint">"Capitals sort descending · Esc closes"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView {
|
fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView {
|
||||||
let value = RwSignal::new(buffer);
|
let value = RwSignal::new(buffer);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
//! Deliberate differences: there is no `q` (quit) in a browser tab, and
|
//! Deliberate differences: there is no `q` (quit) in a browser tab, and
|
||||||
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
|
//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`).
|
||||||
|
|
||||||
|
use crabidy_core::proto::crabidy::QueueSort;
|
||||||
|
|
||||||
use crate::state::Focus;
|
use crate::state::Focus;
|
||||||
|
|
||||||
/// Everything a key can trigger. Mirrors the TUI's `Action` list; the
|
/// Everything a key can trigger. Mirrors the TUI's `Action` list; the
|
||||||
|
|
@ -64,6 +66,83 @@ pub enum Action {
|
||||||
QueueClearKeepCurrent,
|
QueueClearKeepCurrent,
|
||||||
QueueClearAll,
|
QueueClearAll,
|
||||||
QueueSaveAs,
|
QueueSaveAs,
|
||||||
|
/// Drop duplicate queue entries server-side; the count lands in a toast
|
||||||
|
/// (architecture/queue-order.md D16).
|
||||||
|
QueueDedup,
|
||||||
|
/// Open the sort menu dialog.
|
||||||
|
QueueSortMenu,
|
||||||
|
/// Close it without sorting (`Escape`/`q`/`S`).
|
||||||
|
CloseSortMenu,
|
||||||
|
/// Sort by one strategy — what a sort-menu key or the header's select
|
||||||
|
/// control dispatches.
|
||||||
|
QueueSort {
|
||||||
|
sort: QueueSort,
|
||||||
|
descending: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the sort menu: the key that picks it and what it does. The same
|
||||||
|
/// table drives the overlay and the header's select control, so the two can
|
||||||
|
/// never offer different strategies (architecture/queue-order.md D16).
|
||||||
|
pub struct SortChoice {
|
||||||
|
/// Lowercase key; the uppercase form sorts descending, except
|
||||||
|
/// [`QueueSort::Reverse`], which has no direction.
|
||||||
|
pub key: char,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub sort: QueueSort,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sort menu, in display order — the TUI's table, key for key
|
||||||
|
/// (`cbd-tui/src/app/sort.rs`).
|
||||||
|
pub const SORT_CHOICES: &[SortChoice] = &[
|
||||||
|
SortChoice {
|
||||||
|
key: 'a',
|
||||||
|
description: "Artist, then album",
|
||||||
|
sort: QueueSort::Artist,
|
||||||
|
},
|
||||||
|
SortChoice {
|
||||||
|
key: 'l',
|
||||||
|
description: "Album",
|
||||||
|
sort: QueueSort::Album,
|
||||||
|
},
|
||||||
|
SortChoice {
|
||||||
|
key: 't',
|
||||||
|
description: "Title",
|
||||||
|
sort: QueueSort::Title,
|
||||||
|
},
|
||||||
|
SortChoice {
|
||||||
|
key: 'd',
|
||||||
|
description: "Duration",
|
||||||
|
sort: QueueSort::Duration,
|
||||||
|
},
|
||||||
|
SortChoice {
|
||||||
|
key: 'r',
|
||||||
|
description: "Reverse the current order",
|
||||||
|
sort: QueueSort::Reverse,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The action a key pressed inside the sort menu maps to: a sort, closing the
|
||||||
|
/// menu (`Escape`/`q`/`S`), or nothing at all — an unclaimed key leaves the
|
||||||
|
/// menu open rather than falling through to the pane bindings.
|
||||||
|
pub fn sort_menu_key(key: &str) -> Option<Action> {
|
||||||
|
if matches!(key, "Escape" | "q" | "S") {
|
||||||
|
return Some(Action::CloseSortMenu);
|
||||||
|
}
|
||||||
|
let mut chars = key.chars();
|
||||||
|
let (typed, rest) = (chars.next()?, chars.next());
|
||||||
|
if rest.is_some() {
|
||||||
|
// A named key ("Enter", "ArrowDown"): not a strategy.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let choice = SORT_CHOICES
|
||||||
|
.iter()
|
||||||
|
.find(|choice| choice.key == typed.to_ascii_lowercase())?;
|
||||||
|
Some(Action::QueueSort {
|
||||||
|
sort: choice.sort,
|
||||||
|
// Reverse has no direction on the wire (D6).
|
||||||
|
descending: typed.is_ascii_uppercase() && choice.sort != QueueSort::Reverse,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the help overlay: the key label and what it does.
|
/// One row of the help overlay: the key label and what it does.
|
||||||
|
|
@ -271,6 +350,16 @@ pub const HELP: &[HelpEntry] = &[
|
||||||
key: "C",
|
key: "C",
|
||||||
description: "Clear entire queue",
|
description: "Clear entire queue",
|
||||||
},
|
},
|
||||||
|
HelpEntry {
|
||||||
|
scope: "Queue",
|
||||||
|
key: "u",
|
||||||
|
description: "Unique: drop duplicate tracks from the queue",
|
||||||
|
},
|
||||||
|
HelpEntry {
|
||||||
|
scope: "Queue",
|
||||||
|
key: "S",
|
||||||
|
description: "Sort the queue (opens a menu of strategies)",
|
||||||
|
},
|
||||||
HelpEntry {
|
HelpEntry {
|
||||||
scope: "Queue",
|
scope: "Queue",
|
||||||
key: "w",
|
key: "w",
|
||||||
|
|
@ -370,6 +459,8 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
|
||||||
"c" => Some(Action::QueueClearKeepCurrent),
|
"c" => Some(Action::QueueClearKeepCurrent),
|
||||||
"C" => Some(Action::QueueClearAll),
|
"C" => Some(Action::QueueClearAll),
|
||||||
"w" => Some(Action::QueueSaveAs),
|
"w" => Some(Action::QueueSaveAs),
|
||||||
|
"u" => Some(Action::QueueDedup),
|
||||||
|
"S" => Some(Action::QueueSortMenu),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -471,6 +562,71 @@ 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::Library, false, "u", false), None);
|
||||||
|
assert_eq!(lookup(Focus::Library, false, "S", false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sort_menu_maps_keys_to_strategies_and_closes_on_escape() {
|
||||||
|
assert_eq!(
|
||||||
|
sort_menu_key("a"),
|
||||||
|
Some(Action::QueueSort {
|
||||||
|
sort: QueueSort::Artist,
|
||||||
|
descending: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sort_menu_key("T"),
|
||||||
|
Some(Action::QueueSort {
|
||||||
|
sort: QueueSort::Title,
|
||||||
|
descending: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Reverse has no direction on the wire (D6).
|
||||||
|
for key in ["r", "R"] {
|
||||||
|
assert_eq!(
|
||||||
|
sort_menu_key(key),
|
||||||
|
Some(Action::QueueSort {
|
||||||
|
sort: QueueSort::Reverse,
|
||||||
|
descending: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for closer in ["Escape", "q", "S"] {
|
||||||
|
assert_eq!(sort_menu_key(closer), Some(Action::CloseSortMenu));
|
||||||
|
}
|
||||||
|
// An unclaimed key leaves the menu open and does nothing.
|
||||||
|
for stray in ["c", "x", "Enter", "ArrowDown"] {
|
||||||
|
assert_eq!(sort_menu_key(stray), None, "{stray} is not a strategy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both menus offer the same strategies under the same keys, which is only
|
||||||
|
/// true while this table and `cbd-tui/src/app/sort.rs` agree.
|
||||||
|
#[test]
|
||||||
|
fn the_sort_table_is_consistent() {
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for choice in SORT_CHOICES {
|
||||||
|
assert!(choice.key.is_ascii_lowercase(), "{:?}", choice.key);
|
||||||
|
assert!(!seen.contains(&choice.key), "duplicate {:?}", choice.key);
|
||||||
|
assert!(!choice.description.trim().is_empty());
|
||||||
|
seen.push(choice.key);
|
||||||
|
}
|
||||||
|
assert_eq!(seen, vec!['a', 'l', 't', 'd', 'r']);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_action_reachable_from_help_table() {
|
fn every_action_reachable_from_help_table() {
|
||||||
// The help overlay documents at least every scope we bind.
|
// The help overlay documents at least every scope we bind.
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DedupQueueRequest,
|
||||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
DeleteLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
|
||||||
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest,
|
GetUpdateStreamResponse, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
|
||||||
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest,
|
QueueRequest, QueueSort, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest,
|
||||||
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
RestartTrackRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, SortQueueRequest,
|
||||||
ToggleShuffleRequest,
|
ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
||||||
};
|
};
|
||||||
use tonic::{
|
use tonic::{
|
||||||
metadata::MetadataValue,
|
metadata::MetadataValue,
|
||||||
|
|
@ -230,6 +230,26 @@ impl Rpc {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drops duplicate queue entries; returns how many went
|
||||||
|
/// (architecture/queue-order.md D2).
|
||||||
|
pub async fn dedup_queue(&mut self) -> Result<u32, Status> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.dedup_queue(Request::new(DedupQueueRequest {}))
|
||||||
|
.await?;
|
||||||
|
Ok(response.into_inner().removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorders the queue; the new order arrives on the update stream.
|
||||||
|
pub async fn sort_queue(&mut self, sort: QueueSort, descending: bool) -> Result<(), Status> {
|
||||||
|
let request = Request::new(SortQueueRequest {
|
||||||
|
sort: sort as i32,
|
||||||
|
descending,
|
||||||
|
});
|
||||||
|
let _ = self.client.sort_queue(request).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_current(&mut self, position: u32) -> Result<(), Status> {
|
pub async fn set_current(&mut self, position: u32) -> Result<(), Status> {
|
||||||
let request = Request::new(SetCurrentRequest { position });
|
let request = Request::new(SetCurrentRequest { position });
|
||||||
let _ = self.client.set_current(request).await?;
|
let _ = self.client.set_current(request).await?;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ pub enum Dialog {
|
||||||
Login,
|
Login,
|
||||||
/// The `?` key binding overlay.
|
/// The `?` key binding overlay.
|
||||||
Help,
|
Help,
|
||||||
|
/// The queue sort menu (`S`): one keypress picks a strategy. Keys reach
|
||||||
|
/// the dispatcher while it is open, like `Help`
|
||||||
|
/// (architecture/queue-order.md D14, D16).
|
||||||
|
Sort,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a library listing may be cached client-side — same rule as
|
/// Whether a library listing may be cached client-side — same rule as
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,32 @@ input {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The queue sort menu: one row per strategy, pickable by click as well as by
|
||||||
|
its letter (architecture/queue-order.md D16). */
|
||||||
|
.sort-choice {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& td {
|
||||||
|
padding: 0.25rem 0.6rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .key {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-block-end: 4.5rem;
|
inset-block-end: 4.5rem;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,14 @@ service CrabidyService {
|
||||||
rpc Remove(RemoveRequest) returns (RemoveResponse);
|
rpc Remove(RemoveRequest) returns (RemoveResponse);
|
||||||
rpc Insert(InsertRequest) returns (InsertResponse);
|
rpc Insert(InsertRequest) returns (InsertResponse);
|
||||||
rpc ClearQueue(ClearQueueRequest) returns (ClearQueueResponse);
|
rpc ClearQueue(ClearQueueRequest) returns (ClearQueueResponse);
|
||||||
|
// Drops duplicate entries, keeping one copy of each track. Never removes
|
||||||
|
// the playing entry, so it cannot interrupt playback. Answers with the
|
||||||
|
// number of entries removed (architecture/queue-order.md D2).
|
||||||
|
rpc DedupQueue(DedupQueueRequest) returns (DedupQueueResponse);
|
||||||
|
// Reorders the queue by one of the `QueueSort` strategies. The playing
|
||||||
|
// track keeps playing at its new position; the new order arrives on the
|
||||||
|
// update stream (architecture/queue-order.md).
|
||||||
|
rpc SortQueue(SortQueueRequest) returns (SortQueueResponse);
|
||||||
rpc SetCurrent(SetCurrentRequest) returns (SetCurrentResponse);
|
rpc SetCurrent(SetCurrentRequest) returns (SetCurrentResponse);
|
||||||
rpc ToggleShuffle(ToggleShuffleRequest) returns (ToggleShuffleResponse);
|
rpc ToggleShuffle(ToggleShuffleRequest) returns (ToggleShuffleResponse);
|
||||||
rpc ToggleRepeat(ToggleRepeatRequest) returns (ToggleRepeatResponse);
|
rpc ToggleRepeat(ToggleRepeatRequest) returns (ToggleRepeatResponse);
|
||||||
|
|
@ -182,6 +190,50 @@ message ClearQueueRequest {
|
||||||
}
|
}
|
||||||
message ClearQueueResponse {}
|
message ClearQueueResponse {}
|
||||||
|
|
||||||
|
// De-duplication of the live queue (architecture/queue-order.md). Two
|
||||||
|
// entries are the same track when they carry the same `provider_item_id`
|
||||||
|
// under the same provider (the first path segment) or — when that id is
|
||||||
|
// empty — the same `path`. Deliberately *not* metadata matching: the same
|
||||||
|
// artist and title is routinely a different recording (D3).
|
||||||
|
message DedupQueueRequest {}
|
||||||
|
message DedupQueueResponse {
|
||||||
|
// How many entries were dropped. 0 says the queue held no duplicates,
|
||||||
|
// which is not the same answer as "nothing happened" — hence a response
|
||||||
|
// field where the other queue verbs have none (D2).
|
||||||
|
uint32 removed = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How `SortQueue` orders the queue.
|
||||||
|
//
|
||||||
|
// Every strategy is a *stable* sort, so entries with an equal key keep their
|
||||||
|
// queue order — for a queued album that is its track order. Text compares
|
||||||
|
// case-insensitively and without collation; blank text and unknown durations
|
||||||
|
// sort last in **both** directions (architecture/queue-order.md D7, D8, D9).
|
||||||
|
enum QueueSort {
|
||||||
|
// A client that did not set the field. Rejected as invalid rather than
|
||||||
|
// silently treated as one of the strategies below.
|
||||||
|
QUEUE_SORT_UNSPECIFIED = 0;
|
||||||
|
// Artist, then album title; queue order within an album.
|
||||||
|
QUEUE_SORT_ARTIST = 1;
|
||||||
|
// Album title; queue order within an album.
|
||||||
|
QUEUE_SORT_ALBUM = 2;
|
||||||
|
// Track title.
|
||||||
|
QUEUE_SORT_TITLE = 3;
|
||||||
|
// Duration; tracks with no known duration (web radio) sort last.
|
||||||
|
QUEUE_SORT_DURATION = 4;
|
||||||
|
// Reverses the order the queue is currently in. Not a key: `descending`
|
||||||
|
// is ignored, since reversing descendingly is the same operation.
|
||||||
|
QUEUE_SORT_REVERSE = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SortQueueRequest {
|
||||||
|
QueueSort sort = 1;
|
||||||
|
// Reverses the comparison of `sort` (largest/last first). Ignored by
|
||||||
|
// `QUEUE_SORT_REVERSE`. Blanks and unknown durations still sort last.
|
||||||
|
bool descending = 2;
|
||||||
|
}
|
||||||
|
message SortQueueResponse {}
|
||||||
|
|
||||||
// Stream
|
// Stream
|
||||||
message GetUpdateStreamRequest {}
|
message GetUpdateStreamRequest {}
|
||||||
message GetUpdateStreamResponse {
|
message GetUpdateStreamResponse {
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,11 @@ pub fn minimum_role(grpc_path: &str) -> Role {
|
||||||
Role::QueueAppender
|
Role::QueueAppender
|
||||||
}
|
}
|
||||||
// Every other queue and playback verb.
|
// Every other queue and playback verb.
|
||||||
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
|
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "DedupQueue" | "SortQueue"
|
||||||
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
|
| "SetCurrent" | "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop"
|
||||||
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => Role::QueueOwner,
|
| "ChangeVolume" | "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => {
|
||||||
|
Role::QueueOwner
|
||||||
|
}
|
||||||
// Library writes (CaptureLibraryNode, SaveQueue,
|
// Library writes (CaptureLibraryNode, SaveQueue,
|
||||||
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
|
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
|
||||||
_ => Role::Owner,
|
_ => Role::Owner,
|
||||||
|
|
@ -345,6 +347,8 @@ mod tests {
|
||||||
"Remove",
|
"Remove",
|
||||||
"Insert",
|
"Insert",
|
||||||
"ClearQueue",
|
"ClearQueue",
|
||||||
|
"DedupQueue",
|
||||||
|
"SortQueue",
|
||||||
"SetCurrent",
|
"SetCurrent",
|
||||||
"ToggleShuffle",
|
"ToggleShuffle",
|
||||||
"ToggleRepeat",
|
"ToggleRepeat",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub mod crabidy_store;
|
||||||
pub mod orphans;
|
pub mod orphans;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod queue_order;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
#[cfg(feature = "spectrum")]
|
#[cfg(feature = "spectrum")]
|
||||||
|
|
@ -23,7 +24,7 @@ pub mod spectrum;
|
||||||
use audio_player::PlayerMessage;
|
use audio_player::PlayerMessage;
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
||||||
Track,
|
QueueSort, Track,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use rand::{rng, seq::SliceRandom};
|
use rand::{rng, seq::SliceRandom};
|
||||||
|
|
@ -664,6 +665,73 @@ impl QueueManager {
|
||||||
!exclude_current
|
!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`]. 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) -> usize {
|
||||||
|
let positions = queue_order::duplicate_positions(&self.tracks, self.current_position());
|
||||||
|
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
|
/// Restores play_order to a consistent state after an inconsistency was
|
||||||
/// detected. Loses shuffle history but keeps the queue playable.
|
/// detected. Loses shuffle history but keeps the queue playable.
|
||||||
fn rebuild_play_order(&mut self) {
|
fn rebuild_play_order(&mut self) {
|
||||||
|
|
@ -897,6 +965,175 @@ mod tests {
|
||||||
order.sort_unstable();
|
order.sort_unstable();
|
||||||
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dedup and sort (architecture/queue-order.md) -------------------
|
||||||
|
|
||||||
|
/// A track whose artist/title/duration are what a sort reads. The path is
|
||||||
|
/// unique per title so dedup does not conflate them.
|
||||||
|
fn meta(artist: &str, title: &str, duration: Option<u32>) -> Track {
|
||||||
|
Track {
|
||||||
|
path: format!("/tidal/x/{artist}-{title}"),
|
||||||
|
artist: artist.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
duration,
|
||||||
|
..track(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_of(tracks: Vec<Track>) -> QueueManager {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
q.replace_with_tracks(&tracks);
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The play sequence from where the queue stands now, to the end.
|
||||||
|
fn play_sequence(q: &mut QueueManager) -> Vec<String> {
|
||||||
|
let mut seq = vec![q.current_track().expect("a current track").title];
|
||||||
|
while let Some(next) = q.next_track() {
|
||||||
|
seq.push(next.title);
|
||||||
|
}
|
||||||
|
seq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The play order must always stay a permutation of the track indices —
|
||||||
|
/// the invariant every mutation shares (see `shuffle_insert_keeps_order_unique`).
|
||||||
|
fn assert_order_is_a_permutation(q: &QueueManager) {
|
||||||
|
let mut order = q.play_order.clone();
|
||||||
|
order.sort_unstable();
|
||||||
|
assert_eq!(
|
||||||
|
order,
|
||||||
|
(0..q.tracks.len()).collect::<Vec<usize>>(),
|
||||||
|
"play_order must index every track exactly once"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
q.tracks.is_empty() || q.current_offset < q.play_order.len(),
|
||||||
|
"current_offset must point into play_order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_removes_later_copies_and_reports_the_count() {
|
||||||
|
let mut q = queue_of(vec![track(0), track(1), track(0), track(2), track(1)]);
|
||||||
|
assert_eq!(q.dedup(), 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(), 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(), 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(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_leaves_the_queue_playable() {
|
||||||
|
let mut q = queue_of(vec![track(0), track(0), track(1)]);
|
||||||
|
q.dedup();
|
||||||
|
assert_eq!(q.next_track().expect("advances").title, "track 1");
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorting_reorders_the_queue_and_follows_the_current_track() {
|
||||||
|
let mut q = queue_of(vec![
|
||||||
|
meta("b", "beta", None),
|
||||||
|
meta("a", "alpha", None),
|
||||||
|
meta("c", "gamma", None),
|
||||||
|
]);
|
||||||
|
assert!(q.set_current_position(0)); // "beta" is playing
|
||||||
|
q.sort(QueueSort::Artist, false);
|
||||||
|
assert_eq!(titles(&q), vec!["alpha", "beta", "gamma"]);
|
||||||
|
assert_eq!(
|
||||||
|
q.current_track().expect("current").title,
|
||||||
|
"beta",
|
||||||
|
"the playing track stays current at its new position"
|
||||||
|
);
|
||||||
|
assert_eq!(q.current_position(), 1);
|
||||||
|
assert_order_is_a_permutation(&q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shuffle off: the queue order *is* the play order, so a sort decides what
|
||||||
|
/// plays next (D10). The playing track is the one the sort puts first, so
|
||||||
|
/// the whole new order is still ahead of it — with the play order merely
|
||||||
|
/// remapped (the shuffle-on branch) the tail would come out as
|
||||||
|
/// ["alpha", "beta"], missing the track that moved behind the cursor.
|
||||||
|
#[test]
|
||||||
|
fn sorting_without_shuffle_changes_what_plays_next() {
|
||||||
|
let mut q = queue_of(vec![
|
||||||
|
meta("c", "gamma", None),
|
||||||
|
meta("a", "alpha", None),
|
||||||
|
meta("b", "beta", None),
|
||||||
|
]);
|
||||||
|
assert!(q.set_current_position(1)); // "alpha" is playing
|
||||||
|
q.sort(QueueSort::Artist, false);
|
||||||
|
assert_eq!(play_sequence(&mut q), vec!["alpha", "beta", "gamma"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorting_with_shuffle_on_preserves_the_shuffled_play_sequence() {
|
||||||
|
// Shuffle on: the user asked for a random order, so a sort rewrites
|
||||||
|
// the display order without touching what plays next (D10).
|
||||||
|
let mut q = queue_of((0..6).map(track).collect());
|
||||||
|
q.shuffle_on();
|
||||||
|
q.next_track();
|
||||||
|
let expected = {
|
||||||
|
let mut probe = q.clone();
|
||||||
|
play_sequence(&mut probe)
|
||||||
|
};
|
||||||
|
q.sort(QueueSort::Title, true);
|
||||||
|
assert_eq!(play_sequence(&mut q), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorting_with_shuffle_on_still_reorders_the_visible_queue() {
|
||||||
|
let mut q = queue_of(vec![
|
||||||
|
meta("c", "gamma", None),
|
||||||
|
meta("a", "alpha", None),
|
||||||
|
meta("b", "beta", None),
|
||||||
|
]);
|
||||||
|
q.shuffle_on();
|
||||||
|
q.sort(QueueSort::Title, false);
|
||||||
|
assert_eq!(titles(&q), vec!["alpha", "beta", "gamma"]);
|
||||||
|
assert_order_is_a_permutation(&q);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorting_an_empty_or_single_queue_does_not_panic() {
|
||||||
|
let mut empty = QueueManager::new();
|
||||||
|
empty.sort(QueueSort::Artist, false);
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
let mut one = queue_of(vec![track(0)]);
|
||||||
|
one.sort(QueueSort::Reverse, false);
|
||||||
|
assert_eq!(titles(&one), vec!["track 0"]);
|
||||||
|
assert_order_is_a_permutation(&one);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unspecified_sort_leaves_the_queue_alone() {
|
||||||
|
let mut q = queue_of(vec![track(1), track(0)]);
|
||||||
|
q.sort(QueueSort::Unspecified, false);
|
||||||
|
assert_eq!(titles(&q), vec!["track 1", "track 0"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/// A command for the provider orchestrator, tagged with the tracing span that
|
/// A command for the provider orchestrator, tagged with the tracing span that
|
||||||
/// was current when it was sent so the handler can attribute its events to
|
/// was current when it was sent so the handler can attribute its events to
|
||||||
|
|
@ -1049,6 +1286,20 @@ pub enum PlaybackCommand {
|
||||||
Clear {
|
Clear {
|
||||||
exclude_current: bool,
|
exclude_current: bool,
|
||||||
},
|
},
|
||||||
|
/// Drops duplicate queue entries and reports how many went
|
||||||
|
/// (architecture/queue-order.md D2). Handled on the loop like every other
|
||||||
|
/// queue mutation; the count travels back through `result_tx` because it
|
||||||
|
/// cannot be recovered from the resulting snapshot.
|
||||||
|
DedupQueue {
|
||||||
|
result_tx: flume::Sender<usize>,
|
||||||
|
},
|
||||||
|
/// Reorders the queue by one strategy (architecture/queue-order.md D6).
|
||||||
|
/// No result channel: the new order is the answer and it goes out on the
|
||||||
|
/// update stream.
|
||||||
|
SortQueue {
|
||||||
|
sort: QueueSort,
|
||||||
|
descending: bool,
|
||||||
|
},
|
||||||
SetCurrent {
|
SetCurrent {
|
||||||
position: u32,
|
position: u32,
|
||||||
},
|
},
|
||||||
|
|
@ -1109,6 +1360,8 @@ impl PlaybackCommand {
|
||||||
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
||||||
Self::ResolveFinished { .. } => "resolve_finished",
|
Self::ResolveFinished { .. } => "resolve_finished",
|
||||||
Self::Clear { .. } => "clear",
|
Self::Clear { .. } => "clear",
|
||||||
|
Self::DedupQueue { .. } => "dedup_queue",
|
||||||
|
Self::SortQueue { .. } => "sort_queue",
|
||||||
Self::SetCurrent { .. } => "set_current",
|
Self::SetCurrent { .. } => "set_current",
|
||||||
#[cfg(feature = "fs")]
|
#[cfg(feature = "fs")]
|
||||||
Self::SaveQueue { .. } => "save_queue",
|
Self::SaveQueue { .. } => "save_queue",
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,42 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::DedupQueue { 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 removed = queue.dedup();
|
||||||
|
if removed > 0 {
|
||||||
|
self.broadcast_queue(&queue);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
};
|
||||||
|
debug!(removed, "de-duplicated the queue");
|
||||||
|
if let Err(err) = result_tx.send(removed) {
|
||||||
|
// The caller gave up (client gone); the queue is already
|
||||||
|
// deduped and broadcast, so this is only a lost count.
|
||||||
|
debug!("dedup result receiver gone: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::SortQueue { sort, descending } => {
|
||||||
|
debug!(?sort, descending, "sorting the queue");
|
||||||
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
queue.sort(sort, descending);
|
||||||
|
// The playing track moved; the `Queue` snapshot carries
|
||||||
|
// `current_position`, so this one broadcast tells clients both
|
||||||
|
// the new order and where the cursor now sits — the same way
|
||||||
|
// `Remove` announces a shifted position.
|
||||||
|
self.broadcast_queue(&queue);
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackCommand::SetCurrent { position } => {
|
PlaybackCommand::SetCurrent { position } => {
|
||||||
debug!(position, "jumping to queue position");
|
debug!(position, "jumping to queue position");
|
||||||
let track = {
|
let track = {
|
||||||
|
|
@ -1168,6 +1204,148 @@ mod tests {
|
||||||
assert_eq!(snapshot.tracks.len(), 1);
|
assert_eq!(snapshot.tracks.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The queue-order commands are command-level wiring: the ordering itself
|
||||||
|
/// is pinned in `queue_order` and `QueueManager`, so what these check is
|
||||||
|
/// that the loop performs them, answers, and broadcasts.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dedup_command_removes_duplicates_and_replies_with_the_count() {
|
||||||
|
let playback = playback_with(
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
|
let _ = queue.replace_with_tracks(&[track(0), track(1), track(0)]);
|
||||||
|
}
|
||||||
|
let mut updates = playback.update_tx.subscribe();
|
||||||
|
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::DedupQueue { 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 { 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 { 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 { result_tx })
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
rx.borrow().clone().expect("snapshot sent").tracks.len(),
|
||||||
|
2,
|
||||||
|
"a dedup must be persisted"
|
||||||
|
);
|
||||||
|
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::SortQueue {
|
||||||
|
sort: crabidy_core::proto::crabidy::QueueSort::Reverse,
|
||||||
|
descending: false,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let snapshot = rx.borrow().clone().expect("snapshot sent");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.tracks.first().map(|t| t.title.clone()),
|
||||||
|
Some("track 1".to_string()),
|
||||||
|
"a sort must be persisted in its new order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The gap that let the paste bug through: every previous test drove
|
/// The gap that let the paste bug through: every previous test drove
|
||||||
/// either `insert_tracks` or `PendingResolve` directly, so none of them
|
/// either `insert_tracks` or `PendingResolve` directly, so none of them
|
||||||
/// pinned what the **`Insert` command** does with its position. It is an
|
/// pinned what the **`Insert` command** does with its position. It is an
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
//! 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, Copy, 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The de-duplication identity of `track` (D3).
|
||||||
|
pub fn track_id(track: &Track) -> TrackId<'_> {
|
||||||
|
if track.provider_item_id.is_empty() {
|
||||||
|
return TrackId::Path(&track.path);
|
||||||
|
}
|
||||||
|
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) -> Vec<usize> {
|
||||||
|
// The survivor of each group, decided before anything is dropped: the
|
||||||
|
// playing entry when it is in the group, otherwise the first one seen.
|
||||||
|
// `current` is looked up once, so a group whose survivor is the playing
|
||||||
|
// entry can still drop entries that come *before* it (D4).
|
||||||
|
let playing = tracks.get(current).map(track_id);
|
||||||
|
let mut survivors: HashMap<TrackId<'_>, usize> = HashMap::new();
|
||||||
|
for (pos, track) in tracks.iter().enumerate() {
|
||||||
|
let id = track_id(track);
|
||||||
|
let survivor = survivors.entry(id).or_insert(pos);
|
||||||
|
if Some(id) == playing {
|
||||||
|
*survivor = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(pos, track)| survivors.get(&track_id(track)) != Some(pos))
|
||||||
|
.map(|(pos, _)| pos)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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), track_id(&via_playlist));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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), track_id(&jamendo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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), track_id(&capture));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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), track_id(&same));
|
||||||
|
assert_ne!(track_id(&a), track_id(&other));
|
||||||
|
assert!(matches!(track_id(&a), 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), 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), vec![0]);
|
||||||
|
// With another entry playing, the ordinary rule applies again.
|
||||||
|
assert_eq!(duplicate_positions(&tracks, 1), 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).is_empty());
|
||||||
|
assert!(duplicate_positions(&[], 0).is_empty());
|
||||||
|
// An out-of-range current (an empty or freshly cleared queue) is not
|
||||||
|
// a panic and protects nothing.
|
||||||
|
assert!(duplicate_positions(&[], 9).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);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
|
AppendRequest, AppendResponse, CaptureLibraryNodeRequest, CaptureLibraryNodeResponse,
|
||||||
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
|
ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, ClearQueueResponse,
|
||||||
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DeleteLibraryNodeRequest,
|
CreateLibraryNodeRequest, CreateLibraryNodeResponse, DedupQueueRequest, DedupQueueResponse,
|
||||||
DeleteLibraryNodeResponse, GetLibraryNodeRequest, GetLibraryNodeResponse,
|
DeleteLibraryNodeRequest, DeleteLibraryNodeResponse, GetLibraryNodeRequest,
|
||||||
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
GetLibraryNodeResponse, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||||
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
|
InitResponse, InsertRequest, InsertResponse, NextRequest, NextResponse, PrevRequest,
|
||||||
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
|
PrevResponse, QueueRequest, QueueResponse, QueueSort, RemoveRequest, RemoveResponse,
|
||||||
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
|
RenameLibraryNodeRequest, RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse,
|
||||||
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest, SeekResponse,
|
RestartTrackRequest, RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest,
|
||||||
SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest,
|
SeekResponse, SetCurrentRequest, SetCurrentResponse, SortQueueRequest, SortQueueResponse,
|
||||||
ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest,
|
StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest,
|
||||||
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest,
|
||||||
|
ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
@ -355,6 +356,54 @@ impl CrabidyService for RpcService {
|
||||||
Ok(Response::new(ClearQueueResponse {}))
|
Ok(Response::new(ClearQueueResponse {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, _request))]
|
||||||
|
async fn dedup_queue(
|
||||||
|
&self,
|
||||||
|
_request: Request<DedupQueueRequest>,
|
||||||
|
) -> Result<Response<DedupQueueResponse>, Status> {
|
||||||
|
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 { result_tx })
|
||||||
|
.await?;
|
||||||
|
let removed = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from playback loop: {err}");
|
||||||
|
Status::internal("playback loop did not reply")
|
||||||
|
})?;
|
||||||
|
Ok(Response::new(DedupQueueResponse {
|
||||||
|
removed: removed as u32,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, request), fields(sort, descending))]
|
||||||
|
async fn sort_queue(
|
||||||
|
&self,
|
||||||
|
request: Request<SortQueueRequest>,
|
||||||
|
) -> Result<Response<SortQueueResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
tracing::Span::current().record("sort", req.sort);
|
||||||
|
tracing::Span::current().record("descending", req.descending);
|
||||||
|
debug!("received sort_queue request");
|
||||||
|
// An unknown discriminant or the proto3 default are both "the client
|
||||||
|
// did not name a strategy" — refused rather than silently sorted one
|
||||||
|
// particular way (architecture/queue-order.md D6).
|
||||||
|
let sort = match QueueSort::try_from(req.sort) {
|
||||||
|
Ok(QueueSort::Unspecified) | Err(_) => {
|
||||||
|
return Err(Status::invalid_argument(
|
||||||
|
"sort must name a strategy: artist, album, title, duration or reverse",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(sort) => sort,
|
||||||
|
};
|
||||||
|
self.send_playback(PlaybackCommand::SortQueue {
|
||||||
|
sort,
|
||||||
|
descending: req.descending,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Response::new(SortQueueResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(position))]
|
#[instrument(skip(self, request), fields(position))]
|
||||||
async fn set_current(
|
async fn set_current(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -651,4 +700,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 {}))
|
||||||
|
.await
|
||||||
|
.expect("accepted");
|
||||||
|
assert_eq!(response.into_inner().removed, 7);
|
||||||
|
responder.await.expect("responder");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ cbd global volume -- -0.1 # lower the volume
|
||||||
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
||||||
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
|
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
|
||||||
<NAME>`, `queue shuffle`, and `queue repeat` change it.
|
<NAME>`, `queue shuffle`, and `queue repeat` change it.
|
||||||
|
- `queue dedup` drops duplicate entries and prints how many went (`0` when
|
||||||
|
there were none); `queue sort <artist|album|title|duration|reverse>
|
||||||
|
[--desc]` reorders it. Both are described under [Order operations:
|
||||||
|
dedup and sort](../queue.md#order-operations-dedup-and-sort) — the
|
||||||
|
strategy is a fixed choice, so a shell completes it and a typo fails
|
||||||
|
before anything reaches the server.
|
||||||
- `global play`/`stop`/`next`/`prev`/`restart`/`mute`, `global
|
- `global play`/`stop`/`next`/`prev`/`restart`/`mute`, `global
|
||||||
volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g.
|
volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g.
|
||||||
`global seek -15`) control playback.
|
`global seek -15`) control playback.
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,8 @@ pane).
|
||||||
| Queue | `d` | Remove selection (into the register) |
|
| Queue | `d` | Remove selection (into the register) |
|
||||||
| Queue | `c` | Clear queue except current (to register) |
|
| Queue | `c` | Clear queue except current (to register) |
|
||||||
| Queue | `C` | Clear entire queue (to register) |
|
| Queue | `C` | Clear entire queue (to register) |
|
||||||
|
| Queue | `u` | Unique: drop duplicate tracks |
|
||||||
|
| Queue | `S` | Sort the queue (opens a strategy menu) |
|
||||||
| Queue | `w` | Save queue under a name |
|
| Queue | `w` | Save queue under a name |
|
||||||
| Queue | `W` | Capture the queue into /crabidy (audio) |
|
| Queue | `W` | Capture the queue into /crabidy (audio) |
|
||||||
| Queue | `/` | Filter this view |
|
| Queue | `/` | Filter this view |
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,61 @@ Two consequences worth knowing:
|
||||||
node expands it to tracks at paste time, and a path that no longer
|
node expands it to tracks at paste time, and a path that no longer
|
||||||
resolves simply does not come back.
|
resolves simply does not come back.
|
||||||
|
|
||||||
|
## Order operations: dedup and sort
|
||||||
|
|
||||||
|
Two verbs rewrite the **order** of what is already in the queue, without
|
||||||
|
re-resolving anything through a provider: `DedupQueue` and `SortQueue`
|
||||||
|
(`architecture/queue-order.md`). Both run on the playback loop, so they are
|
||||||
|
atomic against resolve chunks and other clients, and both are persisted and
|
||||||
|
pushed to every client like any other queue change.
|
||||||
|
|
||||||
|
**Dedup** (`u` in the queue pane, `cbd queue dedup`) keeps one copy of each
|
||||||
|
track. Two entries are the same track when they carry the same
|
||||||
|
`provider_item_id` *under the same provider*, or — when the provider reports
|
||||||
|
no id — the same library path. It is deliberately not metadata matching:
|
||||||
|
identical artist and title is routinely a different recording (a live take, a
|
||||||
|
remaster, a radio edit), and dropping one would be an unrecoverable edit of
|
||||||
|
your queue. Two consequences:
|
||||||
|
|
||||||
|
- A captured copy under `/crabidy` and its streaming original are **not**
|
||||||
|
duplicates. Different providers, and the capture exists to be its own entry.
|
||||||
|
- The survivor of a group is the **playing** entry if it is one of them,
|
||||||
|
otherwise the earliest. A dedup can never interrupt playback.
|
||||||
|
|
||||||
|
The RPC answers with the number of entries removed; `0` is a real answer, and
|
||||||
|
the clients show it ("no duplicates") rather than nothing.
|
||||||
|
|
||||||
|
**Sort** (`S` opens a menu in either client, `cbd queue sort <key> [--desc]`)
|
||||||
|
reorders by one strategy:
|
||||||
|
|
||||||
|
| Strategy | Key in the menu | Orders by |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| artist | `a` | artist, then album |
|
||||||
|
| album | `l` | album title |
|
||||||
|
| title | `t` | track title |
|
||||||
|
| duration | `d` | length |
|
||||||
|
| reverse | `r` | the current order, flipped |
|
||||||
|
|
||||||
|
The capital of each letter sorts descending (`reverse` has no direction). Every
|
||||||
|
sort is **stable**, so entries with an equal key keep their queue order — which
|
||||||
|
for a queued album is its track order, and which lets two sorts compose: sort
|
||||||
|
by title, then by artist, and each artist's tracks stay title-sorted. Text
|
||||||
|
compares case-insensitively and without collation ("The Beatles" sorts under
|
||||||
|
T); blank text and unknown durations sort **last** in both directions, because
|
||||||
|
a length-less stream is not the longest track.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Sorting interacts with the shuffle modifier: with shuffle **off** the queue
|
||||||
|
order is the play order, so a sort changes what plays next. With shuffle
|
||||||
|
**on** the shuffled sequence — and your position in it — is preserved, and the
|
||||||
|
sort only changes the order clients display. The playing track keeps playing
|
||||||
|
either way, at its new position.
|
||||||
|
```
|
||||||
|
|
||||||
|
Both operations apply to what the queue holds at that moment. Tracks still
|
||||||
|
being resolved (the loading indicator) land afterwards, at the end or at their
|
||||||
|
insert index, unsorted — press again once the queue settles.
|
||||||
|
|
||||||
## Seeking inside a track
|
## Seeking inside a track
|
||||||
|
|
||||||
`,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing
|
`,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -1920,3 +1920,60 @@ The first probe run hung, and the cause was my probe: blocking
|
||||||
zbus dispatches on. The shipped binaries are multi-threaded, and the UI runs
|
zbus dispatches on. The shipped binaries are multi-threaded, and the UI runs
|
||||||
on `spawn_blocking`, so the shape is fine — but it is a real trap for anyone
|
on `spawn_blocking`, so the shape is fine — but it is a real trap for anyone
|
||||||
writing another bus test.
|
writing another bus test.
|
||||||
|
|
||||||
|
## Queue order: dedup and sorting (2026-07-29)
|
||||||
|
|
||||||
|
Two verbs that rewrite what is already in the queue rather than adding to it:
|
||||||
|
`DedupQueue` and `SortQueue`, server-side, for every client
|
||||||
|
(`architecture/queue-order.md`). Both run on the playback loop — the single
|
||||||
|
writer — so they are atomic against resolve chunks and other clients, need no
|
||||||
|
re-resolution through providers, and reach the persister and the update stream
|
||||||
|
through the one broadcast site that already existed. Doing either client-side
|
||||||
|
was the tempting shortcut and the wrong one: a client-computed `Remove` races
|
||||||
|
the resolve stream, and "sorting" by re-sending paths as a `Replace` would
|
||||||
|
re-fetch every path and restart playback at the head.
|
||||||
|
|
||||||
|
The whole design lives in two questions. **What is a duplicate?** Not
|
||||||
|
artist+title — identical metadata is routinely a different recording (live,
|
||||||
|
remaster, radio edit), and a wrong merge is unrecoverable queue state, so the
|
||||||
|
key is the provider item id *scoped to its provider* (the content store keys
|
||||||
|
content the same way, so unscoped ids would let two providers' numeric ids
|
||||||
|
collide), falling back to the whole path when a provider reports no id. The
|
||||||
|
honest cost, written down rather than discovered: a captured `/crabidy` copy
|
||||||
|
and its streaming original are not duplicates. **Which copy survives?** The
|
||||||
|
playing one if it is in the group, otherwise the earliest — "keep the first"
|
||||||
|
alone stops the music whenever the playing copy is a later one, so the dedup
|
||||||
|
never removes the current entry and delegates the removal to `remove_tracks`,
|
||||||
|
leaving exactly one code path that maintains `play_order`.
|
||||||
|
|
||||||
|
Sorting's subtlety is the two orders. `tracks` is what clients render and what
|
||||||
|
playback follows with shuffle off; `play_order` is the permutation shuffle
|
||||||
|
owns. So a sort permutes `tracks` and then, with shuffle **off**, rebuilds
|
||||||
|
`play_order` as the identity with `current_offset` on the current track (the
|
||||||
|
sort decides what plays next — the point of sorting), and with shuffle **on**
|
||||||
|
remaps `play_order` through the permutation instead, preserving the shuffled
|
||||||
|
sequence and the position in it (the user asked for random; sorting the display
|
||||||
|
must not silently reshuffle). Both branches keep the playing track playing at
|
||||||
|
its new position. The test that pinned this only pinned it after I fixed the
|
||||||
|
*test*: my first fixture put the current track last after sorting, so the
|
||||||
|
"what plays next" assertion was asserting nothing.
|
||||||
|
|
||||||
|
Five strategies (artist→album, album, title, duration, reverse) with a
|
||||||
|
`descending` flag, stable so equal keys keep queue order — which for a queued
|
||||||
|
album is its track order, and which makes two sorts compose. Keys are built
|
||||||
|
once per track (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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
# 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 — 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). No comparison of artist/title anywhere in the
|
||||||
|
dedup path.
|
||||||
|
- [ ] **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